From 9933cc45977a1eb4ae8f0a22eba5715d64a1360e Mon Sep 17 00:00:00 2001 From: xlz-star <76636541+xlz-star@users.noreply.github.com> Date: Thu, 4 Jun 2026 09:47:07 +0800 Subject: [PATCH 1/6] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E6=84=9F=E5=8F=B9?= =?UTF-8?q?=E5=8F=B7=E7=B3=BB=E7=BB=9F=E5=91=BD=E4=BB=A4=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 实现 ! 前缀系统命令执行能力,支持 CLI、Web 和桌面 WebSocket 入口。 命令会按当前平台选择合适 shell,加载 zsh/bash/fish/PowerShell/cmd 的用户环境,并将执行结果写入会话上下文。 新增 ShellCommandSupportTest 覆盖命令识别、上下文注入、空命令、shell rc/profile 加载以及 Windows PATH 合并逻辑。 已验证:ShellCommandSupportTest 通过、跳过测试打包通过,并完成 CLI 与 Web 端 !command -v rtk 实测。 --- .../codecli/command/ShellCommandSupport.java | 439 ++++++++++++++++++ .../solon/codecli/portal/cli/CliShell.java | 19 +- .../solon/codecli/portal/desktop/WsGate.java | 25 +- .../solon/codecli/portal/web/WebGate.java | 18 +- .../command/ShellCommandSupportTest.java | 137 ++++++ 5 files changed, 633 insertions(+), 5 deletions(-) create mode 100644 soloncode-cli/src/main/java/org/noear/solon/codecli/command/ShellCommandSupport.java create mode 100644 soloncode-cli/src/test/java/org/noear/solon/codecli/command/ShellCommandSupportTest.java 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/CliShell.java b/soloncode-cli/src/main/java/org/noear/solon/codecli/portal/cli/CliShell.java index 501ad8c9..8077aa58 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 @@ -41,6 +41,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; @@ -151,7 +152,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) { @@ -205,7 +208,9 @@ public void run() { continue; } - if (!isCommand(session, input)) { + if (ShellCommandSupport.isShellCommand(input)) { + runShellCommand(session, input); + } else if (!isCommand(session, input)) { performAgentTask(session, input, null); } } catch (Throwable e) { @@ -228,6 +233,14 @@ private void safeChatInput(AgentSession session, String prompt) { } } + private void runShellCommand(AgentSession session, String input) throws Exception { + ShellCommandSupport.Result result = ShellCommandSupport.executeAndInject(session, agentProps.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; @@ -698,4 +711,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/main/java/org/noear/solon/codecli/portal/desktop/WsGate.java b/soloncode-cli/src/main/java/org/noear/solon/codecli/portal/desktop/WsGate.java index 2e1a18cd..8dcc28ea 100644 --- a/soloncode-cli/src/main/java/org/noear/solon/codecli/portal/desktop/WsGate.java +++ b/soloncode-cli/src/main/java/org/noear/solon/codecli/portal/desktop/WsGate.java @@ -35,6 +35,7 @@ import org.noear.solon.ai.harness.command.Command; import org.noear.solon.ai.talents.memory.MemoryTalent; import org.noear.solon.ai.util.CmdUtil; +import org.noear.solon.codecli.command.ShellCommandSupport; import org.noear.solon.codecli.command.WebCommandContext; import org.noear.solon.codecli.config.AgentProperties; import org.noear.solon.ai.agent.react.intercept.HITL; @@ -226,6 +227,21 @@ public void onMessage(WebSocket socket, String text) throws IOException { return; } + if (ShellCommandSupport.isShellCommand(currentInput)) { + ShellCommandSupport.Result result = ShellCommandSupport.executeAndInject( + session, getCommandWorkspace(cwd), currentInput); + socket.send(new ONode().set("type", "command") + .set("sessionId", sessionId) + .set("text", result.toDisplayText()) + .toJson()); + socket.send(new ONode().set("type", "done") + .set("sessionId", sessionId) + .set("modelName", chatModel.getConfig().getNameOrModel()) + .set("totalTokens", 0) + .set("elapsedMs", 0).toJson()); + return; + } + // 流式处理 final String finalSessionId = sessionId; @@ -319,6 +335,13 @@ public void onMessage(WebSocket socket, String text) throws IOException { } } + private String getCommandWorkspace(String cwd) { + if (Assert.isEmpty(cwd) || ".".equals(cwd)) { + return agentPros.getWorkspace(); + } + return cwd; + } + private void onReActChunk(ReActChunk chunk, String finalSessionId, WebSocket socket) { ReActTrace trace = chunk.getTrace(); Long start_time = trace.getOriginalPrompt().attrAs("start_time"); @@ -685,4 +708,4 @@ private void handleFallbackPrompt(WebSocket socket, AgentSession session, ChatMo old.dispose(); } } -} \ No newline at end of file +} diff --git a/soloncode-cli/src/main/java/org/noear/solon/codecli/portal/web/WebGate.java b/soloncode-cli/src/main/java/org/noear/solon/codecli/portal/web/WebGate.java index 612723a2..d0ce9b8f 100644 --- a/soloncode-cli/src/main/java/org/noear/solon/codecli/portal/web/WebGate.java +++ b/soloncode-cli/src/main/java/org/noear/solon/codecli/portal/web/WebGate.java @@ -30,6 +30,7 @@ import org.noear.solon.ai.harness.HarnessEngine; import org.noear.solon.ai.harness.command.Command; import org.noear.solon.ai.util.CmdUtil; +import org.noear.solon.codecli.command.ShellCommandSupport; import org.noear.solon.codecli.command.WebCommandContext; import org.noear.solon.codecli.config.AgentProperties; import org.noear.solon.core.handle.UploadedFile; @@ -326,6 +327,14 @@ public void onChatInput(String sessionId, } } + if (ShellCommandSupport.isShellCommand(currentInput) && imageBlocks.isEmpty()) { + ShellCommandSupport.Result result = ShellCommandSupport.executeAndInject( + session, getCommandWorkspace(sessionCwd), currentInput); + emitToClient(session.getSessionId(), WebChunk.ofCommand(result.toDisplayText())); + emitToClient(session.getSessionId(), WebChunk.ofDone()); + return; + } + Prompt prompt; if (!imageBlocks.isEmpty()) { Contents contents = new Contents(); @@ -474,6 +483,13 @@ private boolean isCommand(AgentSession session, String sessionCwd, String input, return true; } + private String getCommandWorkspace(String sessionCwd) { + if (Assert.isEmpty(sessionCwd) || ".".equals(sessionCwd)) { + return engine.getProps().getWorkspace(); + } + return sessionCwd; + } + /** * 判断指定会话是否有 AI 任务正在执行。 @@ -587,4 +603,4 @@ public void interruptSession(String sessionId) { LOG.error("[WebGate] Interrupt failed for session {}: {}", sessionId, e.getMessage()); } } -} \ 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")); + } +} From e30b38c59d2e8c1d11599f2b7e5ee8f9522d4b0f Mon Sep 17 00:00:00 2001 From: xlz-star <76636541+xlz-star@users.noreply.github.com> Date: Thu, 4 Jun 2026 23:11:26 +0800 Subject: [PATCH 2/6] =?UTF-8?q?=E4=BC=98=E5=8C=96=20CLI=20=E8=A7=A6?= =?UTF-8?q?=E5=8F=91=E6=8F=90=E7=A4=BA=E4=BA=A4=E4=BA=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 参考 Codex CLI 的输入体验,增强 /、@、$、! 在终端输入中的即时提示效果。 @ 改为工作区文件提示,$ 保持技能提示,/ 保持命令与模型提示,! 增加本地命令模式入口。 补充删除触发符时关闭候选提示的逻辑,并更新开屏 Tips 文案。 --- .../codecli/portal/cli/CliCompleter.java | 208 ++++++++++++++---- .../solon/codecli/portal/cli/CliShell.java | 161 +++++++++++++- 2 files changed, 320 insertions(+), 49 deletions(-) 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..d3b28294 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 @@ -21,12 +21,23 @@ import org.jline.reader.ParsedLine; import org.noear.solon.ai.chat.ChatConfig; 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.talents.mount.SkillDir; +import java.io.IOException; +import java.nio.file.FileVisitResult; +import java.nio.file.Files; +import java.nio.file.FileVisitOption; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.SimpleFileVisitor; +import java.nio.file.attribute.BasicFileAttributes; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.EnumSet; import java.util.HashSet; import java.util.List; +import java.util.Locale; import java.util.Set; /** @@ -36,10 +47,28 @@ * @since 2026.4.28 */ public class CliCompleter implements Completer { + private static final int MAX_FILE_CANDIDATES = 80; + private static final int MAX_FILE_SCAN = 2_000; + private static final Set EXCLUDED_DIRS = new HashSet<>(); + + static { + EXCLUDED_DIRS.add(".git"); + EXCLUDED_DIRS.add(".idea"); + EXCLUDED_DIRS.add(".soloncode"); + EXCLUDED_DIRS.add("node_modules"); + EXCLUDED_DIRS.add("target"); + EXCLUDED_DIRS.add("__pycache__"); + EXCLUDED_DIRS.add(".gradle"); + EXCLUDED_DIRS.add(".mvn"); + EXCLUDED_DIRS.add("build"); + } + private final HarnessEngine engine; + private final Path workspace; - public CliCompleter(HarnessEngine engine) { + public CliCompleter(HarnessEngine engine, String workspace) { this.engine = engine; + this.workspace = workspace == null ? null : Paths.get(workspace).toAbsolutePath().normalize(); } @Override @@ -48,63 +77,154 @@ 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("@")) { + completeFiles(word, candidates); + return; + } + + if (word.startsWith("$")) { + completeSkills(word, candidates); + return; + } + + if (word.startsWith("!")) { + candidates.add(new Candidate("!", "! 进入本地命令模式", null, null, null, null, true)); + candidates.add(new Candidate("!pwd", "!pwd 执行一次本地命令", null, null, null, null, true)); + candidates.add(new Candidate("!ls", "!ls 列出当前工作区文件", null, 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 + " " + cmd.description(), null, null, 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() + " " + desc, null, null, 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()); - } + private void completeFiles(String word, List candidates) { + if (workspace == null || Files.isDirectory(workspace) == false) { + return; + } - // 构建补全提示: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) + "..."; - } + String query = normalize(word.substring(1)); + int[] scanned = {0}; + List matches = new ArrayList<>(); + + try { + Files.walkFileTree(workspace, EnumSet.noneOf(FileVisitOption.class), 5, new SimpleFileVisitor() { + @Override + public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) { + if (workspace.equals(dir) == false && isVisibleWorkspacePath(dir) == false) { + return FileVisitResult.SKIP_SUBTREE; } + return collectFileCandidate(dir, query, scanned, matches); + } - candidates.add(new Candidate("$" + skill.getName(), "$" + skill.getName() + " " + desc, null, null, null, null, true)); + @Override + public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) { + if (isVisibleWorkspacePath(file) == false) { + return FileVisitResult.CONTINUE; + } + return collectFileCandidate(file, query, scanned, matches); } + }); + + matches.sort(Comparator + .comparing((Path path) -> Files.isDirectory(path) ? 0 : 1) + .thenComparing(this::toRelativePath, String.CASE_INSENSITIVE_ORDER)); + + for (Path path : matches) { + String relative = toRelativePath(path); + boolean dir = Files.isDirectory(path); + String value = "@" + relative + (dir ? "/" : ""); + String display = value + " " + (dir ? "目录" : "文件"); + candidates.add(new Candidate(value, display, null, null, null, null, true)); } + } catch (IOException ignored) { } } + + private FileVisitResult collectFileCandidate(Path path, String query, int[] scanned, List matches) { + if (workspace.equals(path)) { + return FileVisitResult.CONTINUE; + } + if (scanned[0]++ >= MAX_FILE_SCAN) { + return FileVisitResult.TERMINATE; + } + + String relative = toRelativePath(path); + if (query.length() == 0 || normalize(relative).contains(query)) { + matches.add(path); + } + return matches.size() >= MAX_FILE_CANDIDATES ? FileVisitResult.TERMINATE : FileVisitResult.CONTINUE; + } + + private boolean isVisibleWorkspacePath(Path path) { + Path relative = workspace.relativize(path.toAbsolutePath().normalize()); + for (Path part : relative) { + String name = part.toString(); + if (name.startsWith(".") || EXCLUDED_DIRS.contains(name)) { + return false; + } + } + return true; + } + + private String toRelativePath(Path path) { + return workspace.relativize(path.toAbsolutePath().normalize()).toString().replace('\\', '/'); + } + + 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 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 8077aa58..a308e6d9 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 @@ -19,6 +19,8 @@ import org.jline.reader.LineReader; import org.jline.reader.LineReaderBuilder; 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; @@ -54,6 +56,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; @@ -68,6 +71,7 @@ @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"; @@ -76,6 +80,7 @@ public class CliShell implements Runnable { private final HarnessEngine engine; private final AgentProperties agentProps; private final LoopScheduler loopScheduler; + private int cliHintStart = -1; // ANSI 颜色常量 private final static String @@ -103,8 +108,9 @@ public CliShell(HarnessEngine engine, AgentProperties agentProps, LoopScheduler this.reader = LineReaderBuilder.builder() .terminal(terminal) - .completer(new CliCompleter(engine)) + .completer(new CliCompleter(engine, agentProps.getWorkspace())) .build(); + setupInputHints(); } catch (Throwable e) { LOG.error("JLine initialization failed", e); } @@ -118,6 +124,123 @@ 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); + + 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 handled = selfInsert.apply(); + String binding = impl.getLastBinding(); + + if (handled && isTriggerBinding(binding) && isTokenStart(impl, cursorBefore)) { + cliHintStart = cursorBefore; + impl.callWidget("." + LineReader.LIST_CHOICES); + } + 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 && (deletesTrigger || (activeBefore && hasActiveHint(impl) == false))) { + 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); + } + + private 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 isTokenStart(LineReaderImpl impl, int cursorBefore) { + if (cursorBefore == 0) { + return true; + } + return Character.isWhitespace(impl.getBuffer().atChar(cursorBefore - 1)); + } + + private void clearInputChoices(LineReaderImpl impl) { + cliHintStart = -1; + if (JLINE_POST_FIELD != null) { + try { + JLINE_POST_FIELD.set(impl, null); + } catch (Throwable ignored) { + } + } + impl.callWidget("." + LineReader.REDISPLAY); + } + /** * 预备开始 */ @@ -185,30 +308,57 @@ 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 (ShellCommandSupport.isShellCommand(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); @@ -688,7 +838,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 + " file | " + + RESET + "!(tab)" + DIM + " shell" + RESET); terminal.flush(); } From b333824779821a52077efd004debaa931e65b5f6 Mon Sep 17 00:00:00 2001 From: xlz Date: Wed, 10 Jun 2026 21:48:43 +0800 Subject: [PATCH 3/6] =?UTF-8?q?=E5=85=BC=E5=AE=B9=20@=20=E5=AD=90=E4=BB=A3?= =?UTF-8?q?=E7=90=86=E4=B8=8E=E6=96=87=E4=BB=B6=E8=A1=A5=E5=85=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../codecli/portal/cli/CliCompleter.java | 20 +++ .../codecli/portal/cli/CliCompleterTest.java | 138 ++++++++++++++++++ 2 files changed, 158 insertions(+) create mode 100644 soloncode-cli/src/test/java/org/noear/solon/codecli/portal/cli/CliCompleterTest.java 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 d3b28294..b3d250ee 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 @@ -21,6 +21,7 @@ import org.jline.reader.ParsedLine; import org.noear.solon.ai.chat.ChatConfig; 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.talents.mount.SkillDir; @@ -86,6 +87,7 @@ public void complete(LineReader reader, ParsedLine line, List candida } if (word.startsWith("@")) { + completeAgents(word, candidates); completeFiles(word, candidates); return; } @@ -136,6 +138,20 @@ private void completeSkills(String word, List candidates) { } } + 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 = shorten(agent.getDescription(), 40); + candidates.add(new Candidate("@" + agent.getName(), "@" + agent.getName() + " 子代理" + formatDescription(desc), null, null, null, null, true)); + } + } + } + private void completeFiles(String word, List candidates) { if (workspace == null || Files.isDirectory(workspace) == false) { return; @@ -224,6 +240,10 @@ private String shorten(String desc, int maxLength) { return desc; } + private String formatDescription(String desc) { + return desc.length() == 0 ? "" : " " + desc; + } + private String normalize(String text) { return text.toLowerCase(Locale.ROOT); } 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..bd4c8a8f --- /dev/null +++ b/soloncode-cli/src/test/java/org/noear/solon/codecli/portal/cli/CliCompleterTest.java @@ -0,0 +1,138 @@ +/* + * 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 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.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class CliCompleterTest { + @TempDir + Path tempDir; + + @Test + public void atTriggerCompletesAgentsAndFiles() 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")); + assertTrue(containsDisplayPrefix(candidates, "@reviewer 子代理")); + assertTrue(containsValue(candidates, "@src/")); + assertTrue(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")); + } + + 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 boolean containsValue(List candidates, String value) { + for (Candidate candidate : candidates) { + if (value.equals(candidate.value())) { + return true; + } + } + return false; + } + + private boolean containsDisplayPrefix(List candidates, String displayPrefix) { + for (Candidate candidate : candidates) { + if (candidate.displ() != null && candidate.displ().startsWith(displayPrefix)) { + return true; + } + } + return false; + } + + 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(); + } + } +} From 77be57cc3f7f0508849f2fc20eb609662d0e8353 Mon Sep 17 00:00:00 2001 From: xlz Date: Thu, 11 Jun 2026 12:16:47 +0800 Subject: [PATCH 4/6] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=B7=A5=E5=85=B7?= =?UTF-8?q?=E6=9D=83=E9=99=90=E9=85=8D=E7=BD=AE=E5=B9=B6=E5=9B=9E=E9=80=80?= =?UTF-8?q?=20@=20=E6=96=87=E4=BB=B6=E8=A1=A5=E5=85=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../org/noear/solon/codecli/Configurator.java | 4 +- .../codecli/portal/cli/CliCompleter.java | 121 ++---------------- .../solon/codecli/portal/cli/CliShell.java | 89 ++++++++++++- .../codecli/portal/cli/CliCompleterTest.java | 93 ++++++++++++-- .../codecli/portal/cli/CliShellTest.java | 47 +++++++ 5 files changed, 224 insertions(+), 130 deletions(-) create mode 100644 soloncode-cli/src/test/java/org/noear/solon/codecli/portal/cli/CliShellTest.java 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/portal/cli/CliCompleter.java b/soloncode-cli/src/main/java/org/noear/solon/codecli/portal/cli/CliCompleter.java index b3d250ee..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 @@ -25,17 +25,6 @@ import org.noear.solon.ai.harness.command.Command; import org.noear.solon.ai.talents.mount.SkillDir; -import java.io.IOException; -import java.nio.file.FileVisitResult; -import java.nio.file.Files; -import java.nio.file.FileVisitOption; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.nio.file.SimpleFileVisitor; -import java.nio.file.attribute.BasicFileAttributes; -import java.util.ArrayList; -import java.util.Comparator; -import java.util.EnumSet; import java.util.HashSet; import java.util.List; import java.util.Locale; @@ -48,28 +37,10 @@ * @since 2026.4.28 */ public class CliCompleter implements Completer { - private static final int MAX_FILE_CANDIDATES = 80; - private static final int MAX_FILE_SCAN = 2_000; - private static final Set EXCLUDED_DIRS = new HashSet<>(); - - static { - EXCLUDED_DIRS.add(".git"); - EXCLUDED_DIRS.add(".idea"); - EXCLUDED_DIRS.add(".soloncode"); - EXCLUDED_DIRS.add("node_modules"); - EXCLUDED_DIRS.add("target"); - EXCLUDED_DIRS.add("__pycache__"); - EXCLUDED_DIRS.add(".gradle"); - EXCLUDED_DIRS.add(".mvn"); - EXCLUDED_DIRS.add("build"); - } - private final HarnessEngine engine; - private final Path workspace; public CliCompleter(HarnessEngine engine, String workspace) { this.engine = engine; - this.workspace = workspace == null ? null : Paths.get(workspace).toAbsolutePath().normalize(); } @Override @@ -88,7 +59,6 @@ public void complete(LineReader reader, ParsedLine line, List candida if (word.startsWith("@")) { completeAgents(word, candidates); - completeFiles(word, candidates); return; } @@ -98,9 +68,9 @@ public void complete(LineReader reader, ParsedLine line, List candida } if (word.startsWith("!")) { - candidates.add(new Candidate("!", "! 进入本地命令模式", null, null, null, null, true)); - candidates.add(new Candidate("!pwd", "!pwd 执行一次本地命令", null, null, null, null, true)); - candidates.add(new Candidate("!ls", "!ls 列出当前工作区文件", null, null, null, null, true)); + 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)); } } @@ -109,7 +79,7 @@ private void completeCommands(String word, List candidates) { for (String name : engine.getCommandRegistry().names()) { if (normalize(name).startsWith(prefix)) { Command cmd = engine.getCommandRegistry().find(name); - candidates.add(new Candidate("/" + name, "/" + name + " " + cmd.description(), null, null, null, null, true)); + candidates.add(new Candidate("/" + name, "/" + name, null, cmd.description(), null, null, true)); } } } @@ -133,7 +103,7 @@ private void completeSkills(String word, List candidates) { } String desc = shorten(skill.getDescription(), 40); - candidates.add(new Candidate("$" + skill.getName(), "$" + skill.getName() + " " + desc, null, null, null, null, true)); + candidates.add(new Candidate("$" + skill.getName(), "$" + skill.getName(), null, desc, null, null, true)); } } } @@ -146,85 +116,12 @@ private void completeAgents(String word, List candidates) { } if (normalize(agent.getName()).startsWith(prefix)) { - String desc = shorten(agent.getDescription(), 40); - candidates.add(new Candidate("@" + agent.getName(), "@" + agent.getName() + " 子代理" + formatDescription(desc), null, null, null, null, true)); - } - } - } - - private void completeFiles(String word, List candidates) { - if (workspace == null || Files.isDirectory(workspace) == false) { - return; - } - - String query = normalize(word.substring(1)); - int[] scanned = {0}; - List matches = new ArrayList<>(); - - try { - Files.walkFileTree(workspace, EnumSet.noneOf(FileVisitOption.class), 5, new SimpleFileVisitor() { - @Override - public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) { - if (workspace.equals(dir) == false && isVisibleWorkspacePath(dir) == false) { - return FileVisitResult.SKIP_SUBTREE; - } - return collectFileCandidate(dir, query, scanned, matches); - } - - @Override - public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) { - if (isVisibleWorkspacePath(file) == false) { - return FileVisitResult.CONTINUE; - } - return collectFileCandidate(file, query, scanned, matches); - } - }); - - matches.sort(Comparator - .comparing((Path path) -> Files.isDirectory(path) ? 0 : 1) - .thenComparing(this::toRelativePath, String.CASE_INSENSITIVE_ORDER)); - - for (Path path : matches) { - String relative = toRelativePath(path); - boolean dir = Files.isDirectory(path); - String value = "@" + relative + (dir ? "/" : ""); - String display = value + " " + (dir ? "目录" : "文件"); - candidates.add(new Candidate(value, display, null, null, null, null, true)); + String desc = formatDescription("子代理", shorten(agent.getDescription(), 40)); + candidates.add(new Candidate("@" + agent.getName(), "@" + agent.getName(), null, desc, null, null, true, 0)); } - } catch (IOException ignored) { } } - private FileVisitResult collectFileCandidate(Path path, String query, int[] scanned, List matches) { - if (workspace.equals(path)) { - return FileVisitResult.CONTINUE; - } - if (scanned[0]++ >= MAX_FILE_SCAN) { - return FileVisitResult.TERMINATE; - } - - String relative = toRelativePath(path); - if (query.length() == 0 || normalize(relative).contains(query)) { - matches.add(path); - } - return matches.size() >= MAX_FILE_CANDIDATES ? FileVisitResult.TERMINATE : FileVisitResult.CONTINUE; - } - - private boolean isVisibleWorkspacePath(Path path) { - Path relative = workspace.relativize(path.toAbsolutePath().normalize()); - for (Path part : relative) { - String name = part.toString(); - if (name.startsWith(".") || EXCLUDED_DIRS.contains(name)) { - return false; - } - } - return true; - } - - private String toRelativePath(Path path) { - return workspace.relativize(path.toAbsolutePath().normalize()).toString().replace('\\', '/'); - } - private String shorten(String desc, int maxLength) { if (desc == null) { return ""; @@ -240,8 +137,8 @@ private String shorten(String desc, int maxLength) { return desc; } - private String formatDescription(String desc) { - return desc.length() == 0 ? "" : " " + desc; + private String formatDescription(String type, String desc) { + return desc.length() == 0 ? type : type + " " + desc; } private String normalize(String text) { 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 a308e6d9..642e63f3 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,8 +16,11 @@ 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; @@ -78,9 +81,11 @@ public class CliShell implements Runnable { 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 @@ -99,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() @@ -107,8 +113,9 @@ public CliShell(HarnessEngine engine, AgentProperties agentProps, LoopScheduler .build(); this.reader = LineReaderBuilder.builder() + .appName("SolonCode") .terminal(terminal) - .completer(new CliCompleter(engine, agentProps.getWorkspace())) + .completer(completer) .build(); setupInputHints(); } catch (Throwable e) { @@ -139,6 +146,8 @@ private void setupInputHints() { 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; @@ -161,12 +170,19 @@ private void wrapSelfInsert(LineReaderImpl impl) { 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; - impl.callWidget("." + LineReader.LIST_CHOICES); + showInputChoices(impl); + } else if (handled && activeBefore) { + if (isCompletingActiveHint(impl)) { + showInputChoices(impl); + } else { + clearInputChoices(impl); + } } return handled; }); @@ -185,7 +201,13 @@ private void wrapDeleteWidget(LineReaderImpl impl, String widgetName) { boolean deletesTrigger = deletesTrigger(widgetName, before, cursorBefore); boolean handled = deleteWidget.apply(); - if (handled && (deletesTrigger || (activeBefore && hasActiveHint(impl) == false))) { + if (handled && activeBefore) { + if (deletesTrigger || isCompletingActiveHint(impl) == false) { + clearInputChoices(impl); + } else { + showInputChoices(impl); + } + } else if (handled && deletesTrigger) { clearInputChoices(impl); } return handled; @@ -214,7 +236,7 @@ private boolean hasActiveHint(String buffer) { return cliHintStart >= 0 && isTriggerAt(buffer, cliHintStart); } - private boolean isTriggerAt(String buffer, int index) { + static boolean isTriggerAt(String buffer, int index) { if (index < 0 || index >= buffer.length()) { return false; } @@ -223,6 +245,23 @@ private boolean isTriggerAt(String buffer, int index) { (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; @@ -230,15 +269,53 @@ private boolean isTokenStart(LineReaderImpl impl, int cursorBefore) { 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) { } } - impl.callWidget("." + LineReader.REDISPLAY); } /** @@ -838,7 +915,7 @@ protected void printWelcome(AgentSession session) { RESET + "(esc)" + DIM + " interrupt | " + RESET + "/(tab)" + DIM + " command | " + RESET + "$(tab)" + DIM + " skill | " + - RESET + "@(tab)" + DIM + " file | " + + RESET + "@(tab)" + DIM + " agent | " + RESET + "!(tab)" + DIM + " shell" + RESET); terminal.flush(); 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 index bd4c8a8f..31baaadb 100644 --- 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 @@ -21,6 +21,8 @@ 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; @@ -28,6 +30,7 @@ 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; @@ -36,7 +39,7 @@ public class CliCompleterTest { Path tempDir; @Test - public void atTriggerCompletesAgentsAndFiles() throws Exception { + public void atTriggerCompletesAgentsOnly() throws Exception { Files.createDirectories(tempDir.resolve("src/main")); Files.write(tempDir.resolve("src/main/App.java"), Collections.singletonList("class App {}")); @@ -49,9 +52,9 @@ public void atTriggerCompletesAgentsAndFiles() throws Exception { completer.complete(null, new TestParsedLine("@"), candidates); assertTrue(containsValue(candidates, "@reviewer")); - assertTrue(containsDisplayPrefix(candidates, "@reviewer 子代理")); - assertTrue(containsValue(candidates, "@src/")); - assertTrue(containsValue(candidates, "@src/main/App.java")); + assertEquals("子代理 Code review specialist", findCandidate(candidates, "@reviewer").descr()); + assertFalse(containsValue(candidates, "@src/")); + assertFalse(containsValue(candidates, "@src/main/App.java")); } @Test @@ -69,6 +72,52 @@ public void atTriggerFiltersAgentsByPrefix() throws Exception { 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); @@ -80,22 +129,46 @@ private AgentDefinition createAgent(String name, String description) { 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 true; + return candidate; } } - return false; + return null; } - private boolean containsDisplayPrefix(List candidates, String displayPrefix) { + private int countValuePrefix(List candidates, String prefix) { + int count = 0; for (Candidate candidate : candidates) { - if (candidate.displ() != null && candidate.displ().startsWith(displayPrefix)) { - return true; + if (candidate.value().startsWith(prefix)) { + count++; } } - return false; + return count; } private static class TestParsedLine implements ParsedLine { 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)); + } +} From 321f19218d2475fe3f6635eec562ce6127b1d74b Mon Sep 17 00:00:00 2001 From: xlz Date: Thu, 11 Jun 2026 12:24:24 +0800 Subject: [PATCH 5/6] =?UTF-8?q?=E5=85=BC=E5=AE=B9=E6=9C=80=E6=96=B0?= =?UTF-8?q?=E4=B8=8A=E6=B8=B8=E5=B7=A5=E4=BD=9C=E5=8C=BA=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/org/noear/solon/codecli/portal/cli/CliShell.java | 2 +- .../java/org/noear/solon/codecli/portal/desktop/WsGate.java | 2 +- .../main/java/org/noear/solon/codecli/portal/web/WebGate.java | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) 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 642e63f3..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 @@ -461,7 +461,7 @@ private void safeChatInput(AgentSession session, String prompt) { } private void runShellCommand(AgentSession session, String input) throws Exception { - ShellCommandSupport.Result result = ShellCommandSupport.executeAndInject(session, agentProps.getWorkspace(), input); + 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 ")); diff --git a/soloncode-cli/src/main/java/org/noear/solon/codecli/portal/desktop/WsGate.java b/soloncode-cli/src/main/java/org/noear/solon/codecli/portal/desktop/WsGate.java index 8dcc28ea..a9cb2f3a 100644 --- a/soloncode-cli/src/main/java/org/noear/solon/codecli/portal/desktop/WsGate.java +++ b/soloncode-cli/src/main/java/org/noear/solon/codecli/portal/desktop/WsGate.java @@ -337,7 +337,7 @@ public void onMessage(WebSocket socket, String text) throws IOException { private String getCommandWorkspace(String cwd) { if (Assert.isEmpty(cwd) || ".".equals(cwd)) { - return agentPros.getWorkspace(); + return engine.getWorkspace(); } return cwd; } diff --git a/soloncode-cli/src/main/java/org/noear/solon/codecli/portal/web/WebGate.java b/soloncode-cli/src/main/java/org/noear/solon/codecli/portal/web/WebGate.java index d0ce9b8f..45a4788d 100644 --- a/soloncode-cli/src/main/java/org/noear/solon/codecli/portal/web/WebGate.java +++ b/soloncode-cli/src/main/java/org/noear/solon/codecli/portal/web/WebGate.java @@ -485,7 +485,7 @@ private boolean isCommand(AgentSession session, String sessionCwd, String input, private String getCommandWorkspace(String sessionCwd) { if (Assert.isEmpty(sessionCwd) || ".".equals(sessionCwd)) { - return engine.getProps().getWorkspace(); + return engine.getWorkspace(); } return sessionCwd; } From 48220c153477847ea69264dde7c83a41c636e927 Mon Sep 17 00:00:00 2001 From: xlz Date: Thu, 11 Jun 2026 13:30:16 +0800 Subject: [PATCH 6/6] =?UTF-8?q?=E5=9B=9E=E9=80=80=20Web=20=E5=92=8C?= =?UTF-8?q?=E6=A1=8C=E9=9D=A2=E7=AB=AF=E5=91=BD=E4=BB=A4=E5=85=A5=E5=8F=A3?= =?UTF-8?q?=E6=94=B9=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../solon/codecli/portal/desktop/WsGate.java | 25 +------------------ .../solon/codecli/portal/web/WebGate.java | 18 +------------ 2 files changed, 2 insertions(+), 41 deletions(-) diff --git a/soloncode-cli/src/main/java/org/noear/solon/codecli/portal/desktop/WsGate.java b/soloncode-cli/src/main/java/org/noear/solon/codecli/portal/desktop/WsGate.java index a9cb2f3a..2e1a18cd 100644 --- a/soloncode-cli/src/main/java/org/noear/solon/codecli/portal/desktop/WsGate.java +++ b/soloncode-cli/src/main/java/org/noear/solon/codecli/portal/desktop/WsGate.java @@ -35,7 +35,6 @@ import org.noear.solon.ai.harness.command.Command; import org.noear.solon.ai.talents.memory.MemoryTalent; import org.noear.solon.ai.util.CmdUtil; -import org.noear.solon.codecli.command.ShellCommandSupport; import org.noear.solon.codecli.command.WebCommandContext; import org.noear.solon.codecli.config.AgentProperties; import org.noear.solon.ai.agent.react.intercept.HITL; @@ -227,21 +226,6 @@ public void onMessage(WebSocket socket, String text) throws IOException { return; } - if (ShellCommandSupport.isShellCommand(currentInput)) { - ShellCommandSupport.Result result = ShellCommandSupport.executeAndInject( - session, getCommandWorkspace(cwd), currentInput); - socket.send(new ONode().set("type", "command") - .set("sessionId", sessionId) - .set("text", result.toDisplayText()) - .toJson()); - socket.send(new ONode().set("type", "done") - .set("sessionId", sessionId) - .set("modelName", chatModel.getConfig().getNameOrModel()) - .set("totalTokens", 0) - .set("elapsedMs", 0).toJson()); - return; - } - // 流式处理 final String finalSessionId = sessionId; @@ -335,13 +319,6 @@ public void onMessage(WebSocket socket, String text) throws IOException { } } - private String getCommandWorkspace(String cwd) { - if (Assert.isEmpty(cwd) || ".".equals(cwd)) { - return engine.getWorkspace(); - } - return cwd; - } - private void onReActChunk(ReActChunk chunk, String finalSessionId, WebSocket socket) { ReActTrace trace = chunk.getTrace(); Long start_time = trace.getOriginalPrompt().attrAs("start_time"); @@ -708,4 +685,4 @@ private void handleFallbackPrompt(WebSocket socket, AgentSession session, ChatMo old.dispose(); } } -} +} \ No newline at end of file diff --git a/soloncode-cli/src/main/java/org/noear/solon/codecli/portal/web/WebGate.java b/soloncode-cli/src/main/java/org/noear/solon/codecli/portal/web/WebGate.java index 45a4788d..612723a2 100644 --- a/soloncode-cli/src/main/java/org/noear/solon/codecli/portal/web/WebGate.java +++ b/soloncode-cli/src/main/java/org/noear/solon/codecli/portal/web/WebGate.java @@ -30,7 +30,6 @@ import org.noear.solon.ai.harness.HarnessEngine; import org.noear.solon.ai.harness.command.Command; import org.noear.solon.ai.util.CmdUtil; -import org.noear.solon.codecli.command.ShellCommandSupport; import org.noear.solon.codecli.command.WebCommandContext; import org.noear.solon.codecli.config.AgentProperties; import org.noear.solon.core.handle.UploadedFile; @@ -327,14 +326,6 @@ public void onChatInput(String sessionId, } } - if (ShellCommandSupport.isShellCommand(currentInput) && imageBlocks.isEmpty()) { - ShellCommandSupport.Result result = ShellCommandSupport.executeAndInject( - session, getCommandWorkspace(sessionCwd), currentInput); - emitToClient(session.getSessionId(), WebChunk.ofCommand(result.toDisplayText())); - emitToClient(session.getSessionId(), WebChunk.ofDone()); - return; - } - Prompt prompt; if (!imageBlocks.isEmpty()) { Contents contents = new Contents(); @@ -483,13 +474,6 @@ private boolean isCommand(AgentSession session, String sessionCwd, String input, return true; } - private String getCommandWorkspace(String sessionCwd) { - if (Assert.isEmpty(sessionCwd) || ".".equals(sessionCwd)) { - return engine.getWorkspace(); - } - return sessionCwd; - } - /** * 判断指定会话是否有 AI 任务正在执行。 @@ -603,4 +587,4 @@ public void interruptSession(String sessionId) { LOG.error("[WebGate] Interrupt failed for session {}: {}", sessionId, e.getMessage()); } } -} +} \ No newline at end of file