diff --git a/.gitignore b/.gitignore index b33a97e08d48baec577016f90432e3a7661d2450..27acb77b2f229f00c766995d8056b9567122b377 100644 --- a/.gitignore +++ b/.gitignore @@ -28,5 +28,8 @@ nbdist/ ### Mac files ### *.DS_Store .vscode +node_modules/ CLAUDE.md TODO.md +/soloncode-cli/src/main/resources/config.yml +work diff --git a/solonbot-sdk/src/main/java/org/noear/solon/bot/core/TerminalSkill.java b/solonbot-sdk/src/main/java/org/noear/solon/bot/core/TerminalSkill.java index e0fbf86cd8cbf2f2279d623d59dfdd8c3f6f0929..6c5ec3fce2cd540800da7407b61d148909ccd52c 100644 --- a/solonbot-sdk/src/main/java/org/noear/solon/bot/core/TerminalSkill.java +++ b/solonbot-sdk/src/main/java/org/noear/solon/bot/core/TerminalSkill.java @@ -3,6 +3,7 @@ package org.noear.solon.bot.core; import org.noear.solon.ai.annotation.ToolMapping; import org.noear.solon.ai.chat.prompt.Prompt; import org.noear.solon.ai.chat.skill.AbsSkill; +import org.noear.solon.ai.chat.tool.Tool; import org.noear.solon.annotation.Param; import org.noear.solon.core.util.Assert; @@ -559,4 +560,11 @@ public class TerminalSkill extends AbsSkill { return "/bin/sh"; } } + + public Tool[] getToolAry(String... names) { + List nameList = Arrays.asList(names); + return this.getTools(null).stream() + .filter(t -> nameList.contains(t.name())) + .toArray(org.noear.solon.ai.chat.tool.Tool[]::new); + } } \ No newline at end of file diff --git a/soloncode-cli/src/main/java/org/noear/solon/bot/codecli/App.java b/soloncode-cli/src/main/java/org/noear/solon/bot/codecli/App.java index 9d277de3e4f674632111e54a43a1c5a5323297d9..f7453877c3602c76ceb768672c43dd2dcae41421 100644 --- a/soloncode-cli/src/main/java/org/noear/solon/bot/codecli/App.java +++ b/soloncode-cli/src/main/java/org/noear/solon/bot/codecli/App.java @@ -24,10 +24,10 @@ import org.noear.solon.ai.agent.AgentSession; import org.noear.solon.ai.agent.AgentSessionProvider; import org.noear.solon.ai.agent.session.FileAgentSession; import org.noear.solon.ai.chat.ChatModel; +import org.noear.solon.bot.codecli.portal.CliShell; import org.noear.solon.bot.core.AgentProperties; import org.noear.solon.bot.codecli.portal.AcpLink; import org.noear.solon.bot.core.AgentKernel; -import org.noear.solon.bot.codecli.portal.CliShell; import org.noear.solon.bot.codecli.portal.WebGate; import java.util.Map; diff --git a/soloncode-cli/src/main/java/org/noear/solon/bot/codecli/portal/CliShell.java b/soloncode-cli/src/main/java/org/noear/solon/bot/codecli/portal/CliShell.java index 0cdb038822e4c8ddc1b38156814ce3cc5c4a12c9..79a20d73c8d7dade21d960a2f95207f72b402e9e 100644 --- a/soloncode-cli/src/main/java/org/noear/solon/bot/codecli/portal/CliShell.java +++ b/soloncode-cli/src/main/java/org/noear/solon/bot/codecli/portal/CliShell.java @@ -15,10 +15,14 @@ */ package org.noear.solon.bot.codecli.portal; +import org.jline.keymap.KeyMap; import org.jline.reader.EndOfFileException; import org.jline.reader.LineReader; import org.jline.reader.LineReaderBuilder; +import org.jline.reader.Reference; import org.jline.reader.UserInterruptException; +import org.jline.reader.impl.LineReaderImpl; +import org.jline.reader.impl.completer.AggregateCompleter; import org.jline.reader.impl.completer.FileNameCompleter; import org.jline.terminal.Attributes; import org.jline.terminal.Terminal; @@ -27,12 +31,15 @@ import org.jline.utils.InfoCmp; import org.noear.solon.ai.agent.AgentSession; import org.noear.solon.ai.agent.react.ReActChunk; import org.noear.solon.ai.agent.react.intercept.HITL; -import org.noear.solon.ai.agent.react.intercept.HITLDecision; import org.noear.solon.ai.agent.react.intercept.HITLTask; import org.noear.solon.ai.agent.react.task.ActionChunk; import org.noear.solon.ai.agent.react.task.ReasonChunk; import org.noear.solon.ai.chat.message.ChatMessage; import org.noear.solon.ai.chat.prompt.Prompt; +import org.noear.solon.bot.codecli.portal.ui.CommandRegistry; +import org.noear.solon.bot.codecli.portal.ui.SlashCommandCompleter; +import org.noear.solon.bot.codecli.portal.ui.MarkdownRenderer; +import org.noear.solon.bot.codecli.portal.ui.StatusBar; import org.noear.solon.bot.core.AgentKernel; import org.noear.solon.core.util.Assert; import org.noear.solon.lang.Preview; @@ -42,12 +49,13 @@ import reactor.core.Disposable; import reactor.core.scheduler.Schedulers; import java.io.File; +import java.util.ArrayList; +import java.util.List; import java.util.Map; -import java.util.concurrent.CountDownLatch; import java.util.concurrent.atomic.AtomicBoolean; /** - * Code CLI 终端 (Claude Code 风格对齐版) + * Code CLI 终端 (printAbove 架构 — 输入始终可用) */ @Preview("3.9.4") public class CliShell implements Runnable { @@ -56,358 +64,783 @@ public class CliShell implements Runnable { private Terminal terminal; private LineReader reader; private final AgentKernel kernel; + private final CommandRegistry commandRegistry; + private StatusBar statusBar; + + // ── 共享状态 ── + private final AtomicBoolean cancelRequested = new AtomicBoolean(false); + private final AtomicBoolean taskRunning = new AtomicBoolean(false); + private volatile boolean thinkingStarted = false; + private volatile boolean thinkingLineStart = true; + + // ── 流式 Markdown 渲染器 ── + private final MarkdownRenderer mdRenderer = new MarkdownRenderer(new MarkdownRenderer.LineOutput() { + @Override + public void append(String styled) { + appendToLineBuffer(styled); + } + + @Override + public void flushLine() { + // Markdown 渲染器要求空行也输出(段落间距) + synchronized (lineBuffer) { + printAboveLine(lineBuffer.toString() + RESET); + lineBuffer.setLength(0); + } + } + }); - // ANSI 颜色常量 - 严格对齐 Claude 极简风 - private final static String - BOLD = "\033[1m", + // ── 行缓冲 (printAbove 逐行输出) ── + private final StringBuilder lineBuffer = new StringBuilder(); + + // ANSI 颜色常量 - 对齐 Go TUI 主题 + private final static String BOLD = "\033[1m", DIM = "\033[2m", - GREEN = "\033[32m", - YELLOW = "\033[33m", - RED = "\033[31m", - CYAN = "\033[36m", + ACCENT = "\033[38;2;255;125;144m", + ACCENT_BOLD = "\033[1;38;2;255;125;144m", + SOFT = "\033[38;2;160;168;184m", + MUTED = "\033[38;2;114;123;137m", + ERROR_COLOR = "\033[38;2;244;124;124m", + WARN = "\033[38;2;232;194;122m", + TEXT = "\033[38;2;243;245;247m", RESET = "\033[0m"; + // 图标常量 - 对齐 Go TUI + private final static String ICON_ASSISTANT = "\u2726", // ✦ + ICON_PROMPT = "\u276F", // ❯ + ICON_TOOL = "\uD83D\uDEE0", // 🛠 + ICON_CROSS = "\u2718", // ✘ + ICON_WARN = "\u26A0", // ⚠ + ICON_THINKING = "\u2699", // ⚙ + ICON_CHECK = "\u2714"; // ✔ + public CliShell(AgentKernel kernel) { this.kernel = kernel; + this.commandRegistry = new CommandRegistry(); + registerBuiltinCommands(); try { this.terminal = TerminalBuilder.builder() - .jna(true).jansi(true).system(true).dumb(true).build(); + .jna(true).jansi(true).system(true) + .signalHandler(Terminal.SignalHandler.SIG_IGN) // 禁止默认信号处理 + .build(); + + // 禁用 ISIG,让 Ctrl+C 作为普通按键传递而不是信号 + Attributes attrs = terminal.getAttributes(); + attrs.setLocalFlag(Attributes.LocalFlag.ISIG, false); + terminal.setAttributes(attrs); + + // 窗口 Resize 信号处理 + terminal.handle(Terminal.Signal.WINCH, signal -> { + if (statusBar != null) { + statusBar.draw(); + } + if (reader != null) { + try { + reader.callWidget(LineReader.REDRAW_LINE); + reader.callWidget(LineReader.REDISPLAY); + } catch (Exception ignored) { + } + } + }); this.reader = LineReaderBuilder.builder() .terminal(terminal) - .completer(new FileNameCompleter()) + .completer(new AggregateCompleter( + new SlashCommandCompleter(commandRegistry), + new FileNameCompleter())) + .option(LineReader.Option.AUTO_LIST, true) + .option(LineReader.Option.AUTO_MENU, true) + .option(LineReader.Option.AUTO_MENU_LIST, true) + .option(LineReader.Option.MENU_COMPLETE, true) + .option(LineReader.Option.LIST_PACKED, true) + .option(LineReader.Option.DISABLE_EVENT_EXPANSION, true) .build(); + + // 补全菜单样式 + reader.setVariable(LineReader.COMPLETION_STYLE_LIST_SELECTION, "fg:white,bg:bright-black,bold"); + reader.setVariable(LineReader.COMPLETION_STYLE_LIST_BACKGROUND, "bg:default"); + reader.setVariable(LineReader.COMPLETION_STYLE_LIST_DESCRIPTION, "fg:bright-black"); + reader.setVariable(LineReader.COMPLETION_STYLE_LIST_STARTING, "fg:cyan"); + reader.setVariable(LineReader.COMPLETION_STYLE_SELECTION, "fg:white,bg:bright-black,bold"); + reader.setVariable(LineReader.COMPLETION_STYLE_BACKGROUND, "bg:default"); + + // 禁用终端蜂鸣(空缓冲区按 Backspace 等场景) + reader.setVariable(LineReader.BELL_STYLE, "none"); + + // ── Widget:/ 自动触发补全 ── + reader.getWidgets().put("slash-auto-complete", () -> { + reader.getBuffer().write('/'); + if ("/".equals(reader.getBuffer().toString().trim())) { + reader.runMacro("\t"); + } + return true; + }); + reader.getKeyMaps().get(LineReader.MAIN) + .bind(new Reference("slash-auto-complete"), "/"); + + // ── Widget:ESC 取消当前 AI 任务 ── + reader.getWidgets().put("cancel-ai-task", () -> { + if (taskRunning.get()) { + cancelRequested.set(true); + Disposable d = currentDisposable; + if (d != null && !d.isDisposed()) { + d.dispose(); + } + // 清空待发送输入 + int discarded = pendingInputs.size(); + pendingInputs.clear(); + if (discarded > 0) { + resetPrompt(); + } + } + return true; + }); + reader.getKeyMaps().get(LineReader.MAIN) + .bind(new Reference("cancel-ai-task"), KeyMap.esc()); + + // ── Widget:Enter 智能提交 ── + // AI 运行期间按 Enter 不让 readLine 返回,避免 ❯ xx 行打断输出流 + reader.getWidgets().put("smart-accept", () -> { + String buf = reader.getBuffer().toString().trim(); + if (taskRunning.get()) { + if (!buf.isEmpty()) { + pendingInputs.add(buf); + updatePromptWithPending(); + } + reader.getBuffer().clear(); + return true; // 消费 Enter,不让 readLine 返回 + } + // HITL 状态下也拦截 + if (HITL.isHitl(currentSession)) { + reader.getBuffer().clear(); + if (!buf.isEmpty()) { + handleHITLInput(buf); + } + return true; + } + // 正常情况:空输入不提交 + if (buf.isEmpty()) { + return true; // 消费 Enter,不做任何事 + } + reader.callWidget(LineReader.ACCEPT_LINE); + return true; + }); + reader.getKeyMaps().get(LineReader.MAIN) + .bind(new Reference("smart-accept"), "\r"); + reader.getKeyMaps().get(LineReader.MAIN) + .bind(new Reference("smart-accept"), "\n"); + + // ── Widget:Ctrl+L 清屏 + 重绘状态栏 ── + reader.getWidgets().put("clear-screen-redraw", () -> { + terminal.puts(InfoCmp.Capability.clear_screen); + terminal.flush(); + if (statusBar != null) { + statusBar.draw(); + } + reader.callWidget(LineReader.REDRAW_LINE); + return true; + }); + reader.getKeyMaps().get(LineReader.MAIN) + .bind(new Reference("clear-screen-redraw"), KeyMap.ctrl('L')); + + // ── Widget:Ctrl+C 清空当前输入(不产生历史记录)── + reader.getWidgets().put("clear-input", () -> { + // 用 kill-whole-line 清空并视觉更新 + reader.getBuffer().clear(); + reader.callWidget(LineReader.REDISPLAY); + return true; + }); + reader.getKeyMaps().get(LineReader.MAIN) + .bind(new Reference("clear-input"), KeyMap.ctrl('C')); + } catch (Exception e) { LOG.error("JLine initialization failed", e); } } + // ═══════════════════════════════════════════════════════════ + // 内置命令注册 + // ═══════════════════════════════════════════════════════════ + + private void registerBuiltinCommands() { + commandRegistry.register("/help", "显示帮助信息", ctx -> { + printHelp(); + }); + + commandRegistry.register("/exit", "退出程序", ctx -> { + terminal.writer().println(DIM + "Exiting..." + RESET); + terminal.flush(); + System.exit(0); + }); + + commandRegistry.register("/init", "重新初始化代码索引", ctx -> { + AgentSession session = ctx.getSession(); + String result = kernel.init(session); + terminal.writer().println(DIM + result + RESET); + terminal.flush(); + }); + + commandRegistry.register("/clear", "清空会话历史", ctx -> { + AgentSession session = ctx.getSession(); + session.clear(); + // 只清屏,不重新创建 StatusBar(Status 是终端单例) + terminal.puts(InfoCmp.Capability.clear_screen); + terminal.flush(); + if (statusBar != null) { + statusBar.draw(); + } + }); + + commandRegistry.register("/model", "显示当前模型信息", ctx -> { + String model = kernel.getProps().getChatModel() != null + ? kernel.getProps().getChatModel().getModel() + : "未配置"; + terminal.writer().println(DIM + "Model: " + RESET + BOLD + model + RESET); + terminal.flush(); + }); + + commandRegistry.register("/compact", "切换精简/详细输出模式", ctx -> { + kernel.getProps().setCliPrintSimplified(!kernel.getProps().isCliPrintSimplified()); + String mode = kernel.getProps().isCliPrintSimplified() ? "精简模式" : "详细模式"; + terminal.writer().println(DIM + "已切换为: " + RESET + BOLD + mode + RESET); + terminal.flush(); + if (statusBar != null) { + statusBar.setCompactMode(kernel.getProps().isCliPrintSimplified()); + } + }); + + commandRegistry.register("/statusbar", "配置状态栏显示内容", ctx -> { + if (statusBar != null && !taskRunning.get()) { + statusBar.showConfigUI(); + } + }); + } + + // ═══════════════════════════════════════════════════════════ + // 主循环 — readLine() 始终活跃 + // ═══════════════════════════════════════════════════════════ + + private volatile AgentSession currentSession; + private volatile Disposable currentDisposable; + private final List pendingInputs = new ArrayList<>(); // AI 运行期间收集的用户输入 + private final String normalPrompt = "\n" + ACCENT_BOLD + ICON_PROMPT + RESET + " "; + + /** 构建带待发送列表的 prompt */ + private String buildPrompt() { + StringBuilder p = new StringBuilder("\n"); + for (String s : pendingInputs) { + p.append(DIM + " \u25B8 " + s + RESET + "\n"); + } + p.append(ACCENT_BOLD + ICON_PROMPT + RESET + " "); + return p.toString(); + } + + /** 更新 prompt(含待发送) — 带 REDRAW */ + private void updatePromptWithPending() { + ((LineReaderImpl) reader).setPrompt(buildPrompt()); + reader.callWidget(LineReader.REDRAW_LINE); + } + + /** 恢复正常 prompt(用 REDISPLAY 彻底刷新,清除多余行) */ + private void resetPrompt() { + ((LineReaderImpl) reader).setPrompt(normalPrompt); + reader.callWidget(LineReader.REDISPLAY); + } + @Override public void run() { printWelcome(); - AgentSession session = kernel.getSession("cli"); - - // 1. 初始化对齐 - kernel.init(session); + currentSession = kernel.getSession("cli"); + kernel.init(currentSession); - // 2. 主循环 while (true) { try { - String promptStr = "\n" + BOLD + CYAN + "User" + RESET + "\n" + BOLD + CYAN + "> " + RESET; String input; try { - input = reader.readLine(promptStr); + input = reader.readLine(normalPrompt); } catch (UserInterruptException e) { continue; } catch (EndOfFileException e) { break; } - if (Assert.isEmpty(input)) continue; + if (Assert.isEmpty(input)) + continue; - if (!isSystemCommand(session, input)) { - terminal.writer().println("\n" + BOLD + "Assistant" + RESET); - performAgentTask(session, input); + if (!isSystemCommand(currentSession, input)) { + // readLine 已经回显了 ❯ input,不需要再 printAbove + printAboveLine("\n" + ACCENT_BOLD + ICON_ASSISTANT + " Assistant" + RESET); + startAgentTask(currentSession, input); } } catch (Throwable e) { - terminal.writer().println("\n" + RED + "! Error: " + RESET + e.getMessage()); + terminal.writer().println("\n" + ERROR_COLOR + ICON_CROSS + " Error: " + RESET + e.getMessage()); + terminal.flush(); } } } - private void performAgentTask(AgentSession session, String input) throws Exception { - String currentInput = input; - final AtomicBoolean isTaskCompleted = new AtomicBoolean(false); - final AtomicBoolean isFirstConversation = new AtomicBoolean(true); - - while (true) { - // 简化状态提示:只在非首次且任务未完成时打印等待符 - if (currentInput == null && !isTaskCompleted.get()) { - terminal.writer().print("\r" + DIM + " ... " + RESET); - terminal.flush(); - } - - CountDownLatch latch = new CountDownLatch(1); - final AtomicBoolean isInterrupted = new AtomicBoolean(false); - final AtomicBoolean isFirstReasonChunk = new AtomicBoolean(true); - - - Disposable disposable = kernel.stream(session.getSessionId(), Prompt.of(currentInput)) - .subscribeOn(Schedulers.boundedElastic()) - .doOnNext(chunk -> { - if (chunk instanceof ReasonChunk) { - // ReasonChunk 非工具调用时,为流式增量(工具调用时为全量,不需要打印) - onReasonChunk((ReasonChunk) chunk, isFirstReasonChunk, isFirstConversation); - } else if (chunk instanceof ActionChunk) { - //ActionChunk 为全量,一次工具调用一个 ActionChunk - onActionChunk((ActionChunk) chunk, isFirstReasonChunk); - } else if (chunk instanceof ReActChunk) { - // ReActChunk 为全量,ReAct 完成任务时的最后答复 - onFinalChunk((ReActChunk) chunk, isFirstReasonChunk, isFirstConversation); - } - }) - .doOnError(e -> { - terminal.writer().println("\n" + RED + "── Error ────────────────" + RESET); - terminal.writer().println(e.getMessage()); - terminal.flush(); - }) - .doFinally(signal -> { - isTaskCompleted.set(true); - latch.countDown(); - }) - .subscribe(); - - // 监听回车中断 - if (disposable == null || disposable.isDisposed()) { - // 处理订阅失败的情况 - return; - } - - waitForTask(latch, disposable, session, isInterrupted); + // ═══════════════════════════════════════════════════════════ + // AI 任务执行(完全异步) + // ═══════════════════════════════════════════════════════════ - if (isInterrupted.get()) { - terminal.writer().println(DIM + "[Task interrupted]" + RESET); - terminal.flush(); - session.addMessage(ChatMessage.ofAssistant("Task interrupted by user.")); - return; - } + private void startAgentTask(AgentSession session, String input) { + taskRunning.set(true); + cancelRequested.set(false); + thinkingStarted = false; - // HITL 处理 (授权交互) - if (HITL.isHitl(session)) { - if (handleHITL(session)) { - currentInput = null; - continue; - } else { - return; - } - } - - if (isTaskCompleted.get()) { - terminal.writer().println(); - terminal.flush(); - return; - } - - currentInput = null; + // 状态栏:任务开始(taskStart 内部自动 draw) + if (statusBar != null) { + statusBar.incrementTurns(); + statusBar.taskStart(); } - } - private void waitForTask(CountDownLatch latch, Disposable disposable, - AgentSession session, AtomicBoolean isInterrupted) throws Exception { - Attributes originalAttributes = terminal.getAttributes(); - try { - terminal.enterRawMode(); - - while (latch.getCount() > 0) { - int c = terminal.reader().read(50); - if (c == 27 || c == '\r' || c == '\n') { - disposable.dispose(); - isInterrupted.set(true); - latch.countDown(); - break; - } + final AtomicBoolean isFirstConversation = new AtomicBoolean(true); + final AtomicBoolean isFirstReasonChunk = new AtomicBoolean(true); + + currentDisposable = kernel.stream(session.getSessionId(), Prompt.of(input)) + .subscribeOn(Schedulers.boundedElastic()) + .doOnNext(chunk -> { + if (cancelRequested.get()) + return; + + if (chunk instanceof ReasonChunk) { + onReasonChunk((ReasonChunk) chunk, isFirstReasonChunk, isFirstConversation); + } else if (chunk instanceof ActionChunk) { + onActionChunk((ActionChunk) chunk, isFirstReasonChunk); + } else if (chunk instanceof ReActChunk) { + onFinalChunk((ReActChunk) chunk, isFirstReasonChunk, isFirstConversation); + } + }) + .doOnError(e -> { + printAboveLine(ERROR_COLOR + " " + ICON_CROSS + " Error: " + RESET + e.getMessage()); + }) + .doFinally(signal -> { + flushLineBuffer(); + + boolean wasCancelled = cancelRequested.getAndSet(false); + taskRunning.set(false); + currentDisposable = null; + + if (wasCancelled) { + printAboveLine(WARN + " [Task cancelled]" + RESET); + int discarded = pendingInputs.size(); + if (discarded > 0) { + printAboveLine(DIM + " (" + discarded + " 条待发送输入已丢弃)" + RESET); + pendingInputs.clear(); + resetPrompt(); + } + session.addMessage(ChatMessage.ofAssistant("Task interrupted by user.")); + // 状态栏:回到 idle + if (statusBar != null) { + statusBar.taskEnd(0); + } + } - if (HITL.isHitl(session)) { - latch.countDown(); - break; - } - } - } finally { - terminal.setAttributes(originalAttributes); - } + // HITL 检查 + if (HITL.isHitl(session)) { + showHITLPrompt(session); + return; + } - latch.await(); + // 待发送输入 → 渲染用户历史 + 合并为一条发送 + if (!pendingInputs.isEmpty() && !wasCancelled) { + // 显示每条待发送输入作为用户历史 + for (String pi : pendingInputs) { + printAboveLine(ACCENT_BOLD + ICON_PROMPT + RESET + " " + pi); + } + String merged = String.join("\n", pendingInputs); + pendingInputs.clear(); + resetPrompt(); + printAboveLine("\n" + ACCENT_BOLD + ICON_ASSISTANT + " Assistant" + RESET); + startAgentTask(session, merged); + } + }) + .subscribe(); } - private boolean handleHITL(AgentSession session) { + // ═══════════════════════════════════════════════════════════ + // HITL 授权(异步:后台线程显示提示,主线程 readLine 输入) + // ═══════════════════════════════════════════════════════════ + + /** 后台线程调用 — 通过 printAbove 显示 HITL 提示 */ + private void showHITLPrompt(AgentSession session) { HITLTask task = HITL.getPendingTask(session); - HITLDecision decision = HITL.getDecision(session, task); + if (task == null) + return; - if (decision != null) { - if (decision.isRejected()) { - return false; - } else { - return true; - } + // 状态栏同步 + if (statusBar != null) { + statusBar.updateStatus("⚠ awaiting approval"); } - terminal.writer().println("\n" + BOLD + YELLOW + "Permission Required" + RESET); + printAboveLine(""); + printAboveLine(MUTED + " " + repeatChar('\u2500', 20) + RESET); + printAboveLine(WARN + " " + ICON_WARN + " Permission Required" + RESET); if ("bash".equals(task.getToolName())) { - terminal.writer().println(DIM + "Command: " + RESET + task.getArgs().get("command")); + printAboveLine(MUTED + " Command: " + RESET + String.valueOf(task.getArgs().get("command"))); + } else { + printAboveLine(MUTED + " Tool: " + RESET + task.getToolName()); } + printAboveLine(""); + printAboveLine(" " + ACCENT_BOLD + ICON_CHECK + " allow" + RESET + MUTED + " 允许执行" + RESET); + printAboveLine(" " + ERROR_COLOR + ICON_CROSS + " deny" + RESET + MUTED + " 拒绝执行" + RESET); + printAboveLine(MUTED + " " + repeatChar('\u2500', 20) + RESET); + printAboveLine(""); + } - String choice = reader.readLine(BOLD + GREEN + "Approve? (y/n) " + RESET).trim().toLowerCase(); - if ("y".equals(choice) || "yes".equals(choice)) { - HITL.approve(session, task.getToolName()); - return true; + /** 主线程调用 — 处理用户在 readLine() 中输入的 HITL 选择 */ + private void handleHITLInput(String input) { + HITLTask task = HITL.getPendingTask(currentSession); + if (task == null) + return; + + String choice = input.trim().toLowerCase(); + if ("allow".equals(choice) || "y".equals(choice) || "yes".equals(choice) || "a".equals(choice)) { + HITL.approve(currentSession, task.getToolName()); + printAboveLine(DIM + " " + ICON_CHECK + " Approved" + RESET); + // 继续 AI ReAct 循环 + startAgentTask(currentSession, null); } else { - HITL.reject(session, task.getToolName()); - terminal.writer().println(DIM + "Action rejected." + RESET); - return false; + HITL.reject(currentSession, task.getToolName()); + printAboveLine(DIM + " " + ICON_CROSS + " Rejected" + RESET); } } - private void onFinalChunk(ReActChunk react, AtomicBoolean isFirstReasonChunk, AtomicBoolean isFirstConversation) { + // ═══════════════════════════════════════════════════════════ + // 流式回调 — 全部通过行缓冲 + printAbove + // ═══════════════════════════════════════════════════════════ + + private void onFinalChunk(ReActChunk react, AtomicBoolean isFirstReasonChunk, + AtomicBoolean isFirstConversation) { if (react.isNormal() == false) { String delta = clearThink(react.getContent()); onReasonChunkDo(delta, isFirstReasonChunk, isFirstConversation); } + flushLineBuffer(); + mdRenderer.flush(); // 确保 Markdown 状态重置 + if (react.getTrace().getMetrics() != null) { - terminal.writer().println(DIM + " (" + react.getTrace().getMetrics().getTotalTokens() + " tokens)" + RESET); + long tokens = react.getTrace().getMetrics().getTotalTokens(); + String timeInfo = statusBar != null ? ", " + statusBar.getTaskTimeText() : ""; + printAboveLine(DIM + " (" + tokens + " tokens" + timeInfo + ")" + RESET); + // 状态栏:任务结束 + if (statusBar != null) { + statusBar.taskEnd(tokens); + } } } - private void onReasonChunk(ReasonChunk reason, AtomicBoolean isFirstReasonChunk, AtomicBoolean isFirstConversation) { + private void onReasonChunk(ReasonChunk reason, AtomicBoolean isFirstReasonChunk, + AtomicBoolean isFirstConversation) { if (!reason.isToolCalls() && reason.hasContent()) { - //打印 think 或者 不是 think - if (kernel.getProps().isThinkPrinted() || !reason.getMessage().isThinking()) { + boolean isThinking = reason.getMessage().isThinking(); + + if (isThinking) { + // ── 思考内容:MUTED 色 + │ 左边线 ── + if (!thinkingStarted) { + flushLineBuffer(); + printAboveLine(MUTED + " " + ICON_THINKING + " Thinking..." + RESET); + thinkingStarted = true; + thinkingLineStart = true; + // 状态栏:thinking(updateStatus 内部自动 draw) + if (statusBar != null) { + statusBar.updateStatus("⚙ thinking"); + } + } + + String delta = clearThink(reason.getContent()); + // 去掉前导空行 + if (thinkingLineStart) { + delta = delta.replaceAll("^[\\n\\r]+", ""); + } + if (Assert.isNotEmpty(delta)) { + for (char ch : delta.toCharArray()) { + if (ch == '\n') { + flushLineBuffer(); + thinkingLineStart = true; + } else if (ch != '\r') { + if (thinkingLineStart) { + appendToLineBuffer(MUTED + " \u2502 "); + thinkingLineStart = false; + } + appendToLineBuffer(String.valueOf(ch)); + } + } + } + } else { + // ── 正常内容 ── + if (thinkingStarted) { + flushLineBuffer(); + printAboveLine(""); // thinking 和正文之间空一行 + thinkingStarted = false; + // 状态栏:进入 responding(updateStatus 内部自动 draw) + if (statusBar != null) { + statusBar.updateStatus("✦ responding"); + } + } String delta = clearThink(reason.getContent()); onReasonChunkDo(delta, isFirstReasonChunk, isFirstConversation); } } } - private void onReasonChunkDo(String delta, AtomicBoolean isFirstReasonChunk, AtomicBoolean isFirstConversation) { + private volatile boolean reasonAtLineStart = true; + + private void onReasonChunkDo(String delta, AtomicBoolean isFirstReasonChunk, + AtomicBoolean isFirstConversation) { if (Assert.isNotEmpty(delta)) { if (isFirstReasonChunk.get()) { String trimmed = delta.replaceAll("^[\\s\\n]+", ""); if (Assert.isNotEmpty(trimmed)) { - if (isFirstConversation.get()) { - terminal.writer().print(" "); - isFirstConversation.set(false); - } else { - terminal.writer().print("\n "); - } - - terminal.writer().print(trimmed.replace("\n", "\n ")); + isFirstConversation.set(false); isFirstReasonChunk.set(false); + mdRenderer.reset(); // 新回合重置渲染器 + mdRenderer.feed(trimmed); } } else { - // 连续的思考内容,保持缩进替换即可 - terminal.writer().print(delta.replace("\n", "\n ")); + mdRenderer.feed(delta); } - terminal.flush(); } } private void onActionChunk(ActionChunk action, AtomicBoolean isFirstReasonChunk) { + // 如果 thinking 还在进行,先结束 + if (thinkingStarted) { + flushLineBuffer(); + thinkingStarted = false; + } + flushLineBuffer(); + if (Assert.isNotEmpty(action.getToolName())) { - // 1. 准备参数字符串 + // 状态栏:工具调用(updateStatus 内部自动 draw) + if (statusBar != null) { + statusBar.updateStatus("⊙ " + action.getToolName()); + } + // 准备参数 StringBuilder argsBuilder = new StringBuilder(); Map args = action.getArgs(); if (args != null && !args.isEmpty()) { args.forEach((k, v) -> { - if (argsBuilder.length() > 0) argsBuilder.append(" "); + if (argsBuilder.length() > 0) + argsBuilder.append(" "); argsBuilder.append(k).append("=").append(v); }); } String argsStr = argsBuilder.toString().replace("\n", " "); - boolean hasBigArgs = argsStr.length() > 100 || (args != null && args.values().stream().anyMatch(v -> v instanceof String && ((String) v).contains("\n"))); - - if (kernel.getProps().isCliPrintSimplified()) { - // --- 简化风格:单行摘要模式 --- - String content = action.getContent() == null ? "" : action.getContent().trim(); - String summary; - if (Assert.isEmpty(content)) { - summary = "completed"; + // 结果摘要 + String content = action.getContent() == null ? "" : action.getContent().trim(); + String summary; + if (Assert.isEmpty(content)) { + summary = "completed"; + } else { + String[] lines = content.split("\n"); + if (lines.length > 1) { + summary = "returned " + lines.length + " lines"; } else { - String[] lines = content.split("\n"); - if (lines.length > 1) { - summary = "returned " + lines.length + " lines"; - } else { - summary = content.length() > 40 ? content.substring(0, 37) + "..." : content; - } + summary = content.length() > 40 ? content.substring(0, 37) + "..." : content; } + } - // 简化模式下,参数也进行极简压缩 + if (kernel.getProps().isCliPrintSimplified()) { + // 简化模式 — 一行式 String shortArgs = argsStr.length() > 40 ? argsStr.substring(0, 37) + "..." : argsStr; - - terminal.writer().println(); - terminal.writer().println(YELLOW + "❯ " + RESET + BOLD + action.getToolName() + RESET + " " + DIM + shortArgs + " (" + summary + ")" + RESET); - terminal.flush(); - + printAboveLine(""); + printAboveLine(SOFT + " " + ICON_TOOL + " " + TEXT + BOLD + action.getToolName() + RESET + + " " + MUTED + shortArgs + " (" + summary + ")" + RESET); } else { - // --- 全量风格 --- - // 1. 打印指令行 - terminal.writer().println(); - if (!hasBigArgs) { - // 短参数直接跟在后面 - terminal.writer().println(YELLOW + "❯ " + RESET + BOLD + action.getToolName() + RESET + " " + DIM + argsStr + RESET); - } else { - // 大参数块,指令名独占一行,参数作为缩进内容打印(类似 write_file 的 content 部分) - terminal.writer().println(YELLOW + "❯ " + RESET + BOLD + action.getToolName() + RESET); - if (args != null) { - args.forEach((k, v) -> { - String val = String.valueOf(v).trim(); - if ("content".equals(k) && val.split("\n").length > 10) { - // 如果是写文件,且内容太长,只显示头尾 - String[] lines = val.split("\n"); - val = lines[0] + "\n ...\n " + lines[lines.length - 1]; - } - terminal.writer().println(DIM + " [" + k + "]: " + val.replace("\n", "\n ") + RESET); - }); - } + // 详细模式 — 工具名 + 缩进参数(无边框) + printAboveLine(""); + printAboveLine(SOFT + " " + ICON_TOOL + " " + TEXT + BOLD + + action.getToolName() + RESET); + + // 参数 + if (args != null && !args.isEmpty()) { + args.forEach((k, v) -> { + String val = String.valueOf(v).trim().replace("\n", " "); + if (val.length() > 80) { + val = val.substring(0, 77) + "..."; + } + printAboveLine(MUTED + " " + k + ": " + val + RESET); + }); } - // 2. 处理工具返回的结果内容 (getContent) - if (Assert.isNotEmpty(action.getContent())) { - // 在参数和结果之间如果内容较多,可以加个小分隔,或者直接缩进打印 - String indentedContent = " " + action.getContent().trim().replace("\n", "\n "); - terminal.writer().println(DIM + indentedContent + RESET); + // 返回结果 + if (Assert.isNotEmpty(content)) { + printAboveLine(""); + String[] contentLines = content.split("\n"); + if (contentLines.length > 10) { + // 只显示首3行 + ... + 末1行 + for (int i = 0; i < 3; i++) { + printAboveLine(MUTED + " " + contentLines[i] + RESET); + } + printAboveLine(MUTED + " ..." + RESET); + printAboveLine(MUTED + " " + contentLines[contentLines.length - 1] + RESET); + } else { + for (String line : contentLines) { + printAboveLine(MUTED + " " + line + RESET); + } + } } - terminal.writer().println(DIM + " (End of output)" + RESET); - terminal.flush(); + printAboveLine(MUTED + " (" + summary + ")" + RESET); } - // 3. 接下来 AI 可能会针对这个结果进行分析 (Reasoning),设置首行缩进标记 isFirstReasonChunk.set(true); } } + // ═══════════════════════════════════════════════════════════ + // 行缓冲 + printAbove 工具方法 + // ═══════════════════════════════════════════════════════════ + + /** 向行缓冲追加内容(不立即输出) */ + private void appendToLineBuffer(String text) { + synchronized (lineBuffer) { + lineBuffer.append(text); + } + } + + /** 将行缓冲内容 flush 到 printAbove(一整行) */ + private void flushLineBuffer() { + synchronized (lineBuffer) { + if (lineBuffer.length() > 0) { + printAboveLine(lineBuffer.toString() + RESET); + lineBuffer.setLength(0); + } + } + } + + /** 通过 printAbove 输出一整行(线程安全 — 与状态栏 draw 同步) */ + private void printAboveLine(String line) { + synchronized (terminal) { + if (reader != null) { + reader.printAbove(line); + } else { + terminal.writer().println(line); + terminal.flush(); + } + } + } + + // ═══════════════════════════════════════════════════════════ + // 工具方法 + // ═══════════════════════════════════════════════════════════ + private String clearThink(String chunk) { return chunk.replaceAll("(?s)<\\s*/?think\\s*>", ""); } private boolean isSystemCommand(AgentSession session, String input) { - String cmd = input.trim().toLowerCase(); - if ("exit".equals(cmd)) { - terminal.writer().println(DIM + "Exiting..." + RESET); - System.exit(0); - return true; - } - if ("init".equals(cmd)) { - String result = kernel.init(session); - terminal.writer().println(DIM + result + RESET); + String cmd = input.trim(); + + if (cmd.startsWith("/")) { + CommandRegistry.CommandContext ctx = new CommandRegistry.CommandContext(session, null); + if (commandRegistry.execute(cmd, ctx)) { + return true; + } + terminal.writer() + .println(ERROR_COLOR + "未知命令: " + RESET + cmd + MUTED + " (输入 /help 查看可用命令)" + RESET); + terminal.flush(); return true; } - if ("clear".equals(cmd)) { - session.clear(); - printWelcome(); // 推荐加上,让用户清屏后不至于面对一个完全的黑洞 - return true; + + String lower = cmd.toLowerCase(); + if ("exit".equals(lower) || "init".equals(lower) || "clear".equals(lower)) { + CommandRegistry.CommandContext ctx = new CommandRegistry.CommandContext(session, null); + return commandRegistry.execute("/" + lower, ctx); } + return false; } + private void printHelp() { + terminal.writer().println(); + terminal.writer().println(TEXT + BOLD + " 可用命令" + RESET); + terminal.writer().println(); + for (CommandRegistry.Command cmd : commandRegistry.getAllCommands()) { + String name = cmd.getName(); + String padded = name + repeatChar(' ', Math.max(1, 14 - name.length())); + terminal.writer() + .println(" " + ACCENT_BOLD + padded + RESET + MUTED + cmd.getDescription() + RESET); + } + terminal.writer().println(); + terminal.writer().println(MUTED + " 快捷键" + RESET); + terminal.writer().println(MUTED + " Esc 中断当前操作" + RESET); + terminal.writer().println(MUTED + " Tab 自动补全命令" + RESET); + terminal.writer().println(MUTED + " Ctrl+C 取消当前输入" + RESET); + terminal.writer().println(MUTED + " Ctrl+D 退出程序" + RESET); + terminal.writer().println(MUTED + " Ctrl+L 清屏" + RESET); + terminal.writer().println(); + terminal.writer().println(MUTED + " " + repeatChar('\u2500', 40) + RESET); + terminal.writer().println(); + terminal.flush(); + } + + private static String repeatChar(char c, int count) { + if (count <= 0) + return ""; + StringBuilder sb = new StringBuilder(count); + for (int i = 0; i < count; i++) { + sb.append(c); + } + return sb.toString(); + } + + // ═══════════════════════════════════════════════════════════ + // 欢迎界面(冻结 — 不允许改动) + // ═══════════════════════════════════════════════════════════ + protected void printWelcome() { + // 初始化状态栏 + this.statusBar = new StatusBar(terminal); + String modelName = kernel.getProps().getChatModel() != null + ? kernel.getProps().getChatModel().getModel() + : "unknown"; + statusBar.setModelName(modelName); + statusBar.setWorkDir(new File(kernel.getProps().getWorkDir()).getAbsolutePath()); + statusBar.setVersion(kernel.getVersion()); + statusBar.setSessionId("cli"); + statusBar.setCompactMode(kernel.getProps().isCliPrintSimplified()); + statusBar.setup(); + terminal.puts(InfoCmp.Capability.clear_screen); terminal.flush(); + statusBar.draw(); // 清屏后重绘 String path = new File(kernel.getProps().getWorkDir()).getAbsolutePath(); - // 连带版本号,紧凑排列 - terminal.writer().println(BOLD + "SolonCode" + RESET + DIM + " " + kernel.getVersion() + RESET); - terminal.writer().println(DIM + path + RESET); - terminal.writer().print(DIM + "Tips: " + RESET + "(esc)" + DIM + " to interrupt output. Commands: " + - RESET + "'exit'" + DIM + " to quit, " + - RESET + "'init'" + DIM + " to refresh, " + - RESET + "'clear'" + DIM + " to reset" + RESET); - - //terminal.writer().println(DIM + "Commands: " + RESET + "exit" + DIM + ", " + RESET + "init (code)" + DIM + ", " + RESET + "clear (session)" + RESET); - // 仅保留一个空行 + String version = kernel.getVersion(); + + // ── ASCII Art Logo (对齐 Go TUI renderWelcomeLogo) ── + terminal.writer().println(); + terminal.writer().println(ACCENT_BOLD + " ███████ ██████ ██ ██████ ███ ██" + RESET + SOFT + BOLD + + " ██████ ██████ ██████ ███████" + RESET); + terminal.writer().println(ACCENT_BOLD + " ██ ██ ██ ██ ██ ██ ████ ██" + RESET + SOFT + BOLD + + " ██ ██ ██ ██ ██ ██" + RESET); + terminal.writer().println(ACCENT_BOLD + " ███████ ██ ██ ██ ██ ██ ██ ██ ██" + RESET + SOFT + BOLD + + " ██ ██ ██ ██ ██ █████" + RESET); + terminal.writer().println(ACCENT_BOLD + " ██ ██ ██ ██ ██ ██ ██ ██ ██" + RESET + SOFT + BOLD + + " ██ ██ ██ ██ ██ ██" + RESET); + terminal.writer().println(ACCENT_BOLD + " ███████ ██████ ██████ ██████ ██ ████" + RESET + SOFT + BOLD + + " ██████ ██████ ██████ ███████" + RESET); + terminal.writer().println(); + + // ── Meta info ── + terminal.writer().println(SOFT + " Model " + RESET + TEXT + BOLD + modelName + RESET); + terminal.writer().println(SOFT + " Dir " + RESET + SOFT + path + RESET); + terminal.writer().println(SOFT + " Ver " + RESET + SOFT + version + RESET); + terminal.writer().println(); + terminal.writer().println(MUTED + " " + ICON_PROMPT + " " + RESET + ACCENT + "Tip" + RESET + SOFT + " Type " + + RESET + TEXT + BOLD + "/help" + RESET + SOFT + " to see all commands" + RESET); + terminal.writer().println(MUTED + " " + ICON_PROMPT + " " + RESET + SOFT + "Use " + RESET + TEXT + BOLD + "Tab" + + RESET + SOFT + " for auto-completion" + RESET); + terminal.writer().println(MUTED + " " + ICON_PROMPT + " " + RESET + SOFT + "Press " + RESET + TEXT + BOLD + + "Esc" + RESET + SOFT + " to cancel operation" + RESET); + terminal.writer().println(); + terminal.writer().println(MUTED + " " + repeatChar('\u2500', 40) + RESET); terminal.writer().println(); terminal.flush(); } diff --git a/soloncode-cli/src/main/java/org/noear/solon/bot/codecli/portal/ui/CommandRegistry.java b/soloncode-cli/src/main/java/org/noear/solon/bot/codecli/portal/ui/CommandRegistry.java new file mode 100644 index 0000000000000000000000000000000000000000..b333aeece350185f109d961b667af8fead37a6e6 --- /dev/null +++ b/soloncode-cli/src/main/java/org/noear/solon/bot/codecli/portal/ui/CommandRegistry.java @@ -0,0 +1,163 @@ +/* + * 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.bot.codecli.portal.ui; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; + +/** + * CLI 命令注册中心 + * + *

+ * 统一管理所有 / 命令的注册、查找和执行 + *

+ * + * @author noear + * @since 0.0.19 + */ +public class CommandRegistry { + + /** + * 命令定义 + */ + public static class Command { + private final String name; + private final String description; + private final Consumer handler; + + public Command(String name, String description, Consumer handler) { + this.name = name; + this.description = description; + this.handler = handler; + } + + public String getName() { + return name; + } + + public String getDescription() { + return description; + } + + public Consumer getHandler() { + return handler; + } + } + + /** + * 命令执行上下文 + */ + public static class CommandContext { + private final Object session; + private final Runnable onExit; + + public CommandContext(Object session, Runnable onExit) { + this.session = session; + this.onExit = onExit; + } + + @SuppressWarnings("unchecked") + public T getSession() { + return (T) session; + } + + public void exit() { + if (onExit != null) { + onExit.run(); + } + } + } + + private final Map commands = new LinkedHashMap(); + + /** + * 注册命令 + * + * @param name 命令名称(如 "/exit") + * @param description 命令描述 + * @param handler 执行逻辑 + */ + public void register(String name, String description, Consumer handler) { + commands.put(name.toLowerCase(), new Command(name, description, handler)); + } + + /** + * 获取所有已注册命令 + */ + public List getAllCommands() { + return Collections.unmodifiableList(new ArrayList(commands.values())); + } + + /** + * 获取所有命令名称 + */ + public List getCommandNames() { + return new ArrayList(commands.keySet()); + } + + /** + * 根据前缀查找匹配的命令候选 + * + * @param prefix 前缀(如 "/" 或 "/he") + * @return 匹配的命令列表 + */ + public List findCandidates(String prefix) { + if (prefix == null || prefix.isEmpty()) { + return getAllCommands(); + } + + String lowerPrefix = prefix.toLowerCase(); + List result = new ArrayList(); + for (Command cmd : commands.values()) { + if (cmd.getName().toLowerCase().startsWith(lowerPrefix)) { + result.add(cmd); + } + } + return result; + } + + /** + * 执行命令 + * + * @param input 用户输入 + * @param context 命令上下文 + * @return true 如果匹配到了命令并执行 + */ + public boolean execute(String input, CommandContext context) { + if (input == null || input.isEmpty()) { + return false; + } + + String key = input.toLowerCase(); + Command cmd = commands.get(key); + if (cmd != null) { + cmd.getHandler().accept(context); + return true; + } + return false; + } + + /** + * 检查是否存在指定命令 + */ + public boolean hasCommand(String name) { + return commands.containsKey(name.toLowerCase()); + } +} diff --git a/soloncode-cli/src/main/java/org/noear/solon/bot/codecli/portal/ui/MarkdownRenderer.java b/soloncode-cli/src/main/java/org/noear/solon/bot/codecli/portal/ui/MarkdownRenderer.java new file mode 100644 index 0000000000000000000000000000000000000000..a862855acc1052d36776575e7ad34fa387726e8e --- /dev/null +++ b/soloncode-cli/src/main/java/org/noear/solon/bot/codecli/portal/ui/MarkdownRenderer.java @@ -0,0 +1,635 @@ +/* + * 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 + */ +package org.noear.solon.bot.codecli.portal.ui; + +/** + * 流式 Markdown 渲染器 — 接收逐字符/逐 token 输入,输出 ANSI 彩色终端文本 + * + *

+ * 支持: + *

    + *
  • **bold** → ANSI 粗体
  • + *
  • `inline code` → 高亮色
  • + *
  • ```code block``` → 代码块样式 + 语言标签
  • + *
  • # Header → 粗体粗色(行首判断)
  • + *
  • - list / 1. list → 项目符号
  • + *
  • > blockquote → 引用样式
  • + *
  • --- / *** → 水平分隔线
  • + *
+ * + * @author solon-cli + */ +public class MarkdownRenderer { + + // ── ANSI 样式 ── + private static final String RESET = "\033[0m"; + private static final String ITALIC = "\033[3m"; + + // 颜色 + private static final String C_HEADER = "\033[1;38;2;255;125;144m"; + private static final String C_CODE_INLINE = "\033[38;2;232;194;122m"; + private static final String C_CODE_BLOCK = "\033[38;2;165;214;132m"; + private static final String C_CODE_BORDER = "\033[38;2;60;65;75m"; + private static final String C_CODE_LANG = "\033[2;38;2;114;123;137m"; + private static final String C_BOLD = "\033[1;38;2;243;245;247m"; + private static final String C_LIST_BULLET = "\033[38;2;255;125;144m"; + private static final String C_LIST_NUM = "\033[38;2;130;170;255m"; + private static final String C_BLOCKQUOTE = "\033[38;2;114;123;137m"; + private static final String C_HR = "\033[38;2;60;65;75m"; + private static final String C_STRIKE = "\033[9;38;2;114;123;137m"; + private static final String C_TABLE_BORDER = "\033[38;2;80;90;110m"; + private static final String C_TABLE_HEADER = "\033[1;38;2;200;210;220m"; + + // ── 渲染状态 ── + private enum State { + NORMAL, + IN_BOLD, + IN_ITALIC, + IN_STRIKETHROUGH, // ~~...~~ + IN_CODE_INLINE, + IN_CODE_BLOCK, + IN_HEADER, + IN_BLOCKQUOTE, + } + + private State state = State.NORMAL; + private boolean atLineStart = true; + private boolean inTableRow = false; // 表格行模式 + private boolean isTableDivider = false; // |---| 分隔行 + private boolean isFirstTableRow = true; // 第一行表头 + private final StringBuilder pendingBuf = new StringBuilder(); + private String codeBlockLang = ""; + + // ── 输出回调 ── + private final LineOutput output; + + /** + * 输出回调接口 + */ + @FunctionalInterface + public interface LineOutput { + /** 追加内容到当前行缓冲 */ + void append(String styled); + + /** 刷新当前行(输出一整行到 printAbove) */ + default void flushLine() { + } + } + + public MarkdownRenderer(LineOutput output) { + this.output = output; + } + + /** 重置状态(新的 AI 回复开始时调用) */ + public void reset() { + state = State.NORMAL; + atLineStart = true; + inTableRow = false; + isTableDivider = false; + isFirstTableRow = true; + pendingBuf.setLength(0); + codeBlockLang = ""; + } + + // ═══════════════════════════════════════════════════════════ + // 核心:逐字符处理 + // ═══════════════════════════════════════════════════════════ + + /** 喂入一个 delta 文本块(可能是单字符或多字符) */ + public void feed(String delta) { + for (int i = 0; i < delta.length(); i++) { + char ch = delta.charAt(i); + feedChar(ch); + } + } + + private void feedChar(char ch) { + // 有未决缓冲时,先判断能否凑出完整标记 + if (pendingBuf.length() > 0) { + pendingBuf.append(ch); + if (resolvePending()) { + return; + } + // 如果无法解析但缓冲太长(不可能匹配任何标记),flush 作为普通文本 + // 最长可能的标记:###### + 空格 = 7,数字+. +空格 可能有 10+ + if (pendingBuf.length() > 10) { + String text = pendingBuf.toString(); + pendingBuf.setLength(0); + for (char c : text.toCharArray()) { + emitChar(c); + } + } + return; + } + + // 代码块内:直接原样输出 + if (state == State.IN_CODE_BLOCK) { + handleCodeBlock(ch); + return; + } + + // 可能是标记开头 + if (ch == '*' || ch == '`' || ch == '~') { + pendingBuf.append(ch); + return; + } + + // 行首标记检测 + if (atLineStart && state == State.NORMAL) { + if (ch == '#') { + pendingBuf.append(ch); + return; + } + if (ch == '>') { + state = State.IN_BLOCKQUOTE; + output.append(" " + C_BLOCKQUOTE + "│ " + RESET + C_BLOCKQUOTE); + atLineStart = false; + return; + } + if (ch == '-') { + pendingBuf.append(ch); + return; + } + // ___ 分割线 + if (ch == '_') { + pendingBuf.append(ch); + return; + } + // 表格行: 以 | 开头 + if (ch == '|') { + inTableRow = true; + isTableDivider = false; + output.append(" "); + output.append(C_TABLE_BORDER + "│" + RESET); + atLineStart = false; + return; + } + // 有序列表: 1. 2. 3. ... + if (ch >= '0' && ch <= '9') { + pendingBuf.append(ch); + return; + } + } + + // 换行处理 + if (ch == '\n') { + handleNewline(); + return; + } + if (ch == '\r') { + return; // 忽略 \r + } + + emitChar(ch); + } + + // ═══════════════════════════════════════════════════════════ + // 未决缓冲解析 + // ═══════════════════════════════════════════════════════════ + + /** 尝试解析 pendingBuf 中的标记,返回 true 表示已处理 */ + private boolean resolvePending() { + String buf = pendingBuf.toString(); + + // ```: 代码块开始/结束 + if (buf.equals("```")) { + pendingBuf.setLength(0); + if (state == State.IN_CODE_BLOCK) { + // 结束代码块 + output.append(RESET); + output.flushLine(); + output.append(" " + C_CODE_BORDER + "└" + repeatChar('─', 40) + RESET); + output.flushLine(); + state = State.NORMAL; + atLineStart = true; + } else { + // 开始代码块 — 后面可能跟语言名 + state = State.IN_CODE_BLOCK; + codeBlockLang = ""; + // 语言名在后续字符中收集(到换行为止) + } + return true; + } + if (buf.length() < 3 && buf.charAt(0) == '`' && (buf.length() == 1 || buf.charAt(buf.length() - 1) == '`')) { + return false; // 继续等待,可能是 ``` 的一部分 + } + + // `: 行内代码 + if (buf.equals("`") && pendingBuf.length() == 1) { + // 等一下,看看下一个是不是也是 ` + return false; + } + if (buf.length() == 2 && buf.equals("``")) { + return false; // 等第三个字符 + } + if (buf.startsWith("`") && !buf.startsWith("``")) { + // 确认是单 ` — 行内代码切换 + pendingBuf.setLength(0); + if (state == State.IN_CODE_INLINE) { + output.append(RESET); + state = State.NORMAL; + } else if (state == State.NORMAL || state == State.IN_HEADER || state == State.IN_BLOCKQUOTE) { + state = State.IN_CODE_INLINE; + output.append(C_CODE_INLINE); + } + // buf 剩余字符(第2个字符开始)继续喂入 + for (int i = 1; i < buf.length(); i++) { + feedChar(buf.charAt(i)); + } + return true; + } + + // **: 粗体 + if (buf.equals("**")) { + return false; // 等下一个字符确认不是第3个 * + } + if (buf.startsWith("**") && buf.length() > 2) { + pendingBuf.setLength(0); + if (state == State.IN_BOLD) { + output.append(RESET); + state = State.NORMAL; + } else if (state == State.NORMAL) { + state = State.IN_BOLD; + output.append(C_BOLD); + } + // 第3个字符开始继续喂入 + for (int i = 2; i < buf.length(); i++) { + feedChar(buf.charAt(i)); + } + return true; + } + + // *: 可能是斜体或列表符号 + if (buf.equals("*")) { + return false; // 等下一个字符 + } + if (buf.startsWith("*") && !buf.startsWith("**") && buf.length() >= 2) { + pendingBuf.setLength(0); + if (atLineStart && buf.charAt(1) == ' ') { + // 无序列表符号 + output.append(" " + C_LIST_BULLET + "• " + RESET); + atLineStart = false; + // 后续字符 + for (int i = 2; i < buf.length(); i++) { + feedChar(buf.charAt(i)); + } + return true; + } + // 斜体切换 + if (state == State.IN_ITALIC) { + output.append(RESET); + state = State.NORMAL; + } else if (state == State.NORMAL) { + state = State.IN_ITALIC; + output.append(ITALIC); + } + for (int i = 1; i < buf.length(); i++) { + feedChar(buf.charAt(i)); + } + return true; + } + + // #: Header + if (buf.matches("^#{1,6}$")) { + return false; // 继续等空格 + } + if (buf.matches("^#{1,6} .*") || (buf.matches("^#{1,6}[^#].*"))) { + if (atLineStart) { + pendingBuf.setLength(0); + int level = 0; + while (level < buf.length() && buf.charAt(level) == '#') + level++; + state = State.IN_HEADER; + output.append(" " + C_HEADER); + atLineStart = false; + // 跳过 # 和空格,输出标题内容 + int start = level; + if (start < buf.length() && buf.charAt(start) == ' ') + start++; + for (int i = start; i < buf.length(); i++) { + feedChar(buf.charAt(i)); + } + return true; + } + } + + // -: 可能是列表项或分隔线 + if (buf.equals("-")) { + return false; + } + if (buf.equals("- ") || (buf.length() == 2 && buf.charAt(0) == '-' && buf.charAt(1) == ' ')) { + pendingBuf.setLength(0); + if (atLineStart) { + output.append(" " + C_LIST_BULLET + "• " + RESET); + atLineStart = false; + return true; + } + } + if (buf.equals("---") || buf.equals("***") || buf.equals("___")) { + pendingBuf.setLength(0); + output.append(" " + C_HR + repeatChar('─', 40) + RESET); + output.flushLine(); + atLineStart = true; + return true; + } + // _ 的区分:___ 是 HR,否则是普通文本 + if (buf.startsWith("_") && buf.length() >= 2 && !buf.equals("__") && !buf.equals("___")) { + pendingBuf.setLength(0); + for (char c : buf.toCharArray()) { + emitChar(c); + } + return true; + } + if (buf.equals("_") || buf.equals("__")) { + return false; // 等有没有更多 _ + } + if (buf.startsWith("-") && buf.length() >= 2 && buf.charAt(1) != '-' && buf.charAt(1) != ' ') { + pendingBuf.setLength(0); + for (char c : buf.toCharArray()) { + emitChar(c); + } + return true; + } + if (buf.startsWith("--") && buf.length() < 3) { + return false; + } + + // ~~: 删除线 + if (buf.equals("~")) { + return false; + } + if (buf.equals("~~")) { + return false; // 等下一个字符确认 + } + if (buf.startsWith("~~") && buf.length() > 2) { + pendingBuf.setLength(0); + if (state == State.IN_STRIKETHROUGH) { + output.append(RESET); + state = State.NORMAL; + } else if (state == State.NORMAL) { + state = State.IN_STRIKETHROUGH; + output.append(C_STRIKE); + } + for (int i = 2; i < buf.length(); i++) { + feedChar(buf.charAt(i)); + } + return true; + } + if (buf.startsWith("~") && !buf.startsWith("~~") && buf.length() >= 2) { + // 单个 ~ 不是标记,当成普通文本 + pendingBuf.setLength(0); + for (char c : buf.toCharArray()) { + emitChar(c); + } + return true; + } + + // 有序列表: 1. 2. 3. ... (行首数字+点+空格) + if (buf.matches("^\\d+$")) { + return false; // 继续等 . 或其他字符 + } + if (buf.matches("^\\d+\\. $")) { + pendingBuf.setLength(0); + if (atLineStart) { + // 提取数字部分 + String num = buf.substring(0, buf.indexOf('.')); + output.append(" " + C_LIST_NUM + num + ". " + RESET); + atLineStart = false; + return true; + } + } + if (buf.matches("^\\d+\\..*") && !buf.matches("^\\d+\\. $")) { + // 数字后面跟 . 但不是列表格式,或已经有后续字符 + if (buf.matches("^\\d+\\. .+")) { + // 是列表,处理 + pendingBuf.setLength(0); + if (atLineStart) { + int dotIdx = buf.indexOf('.'); + String num = buf.substring(0, dotIdx); + output.append(" " + C_LIST_NUM + num + ". " + RESET); + atLineStart = false; + for (int i = dotIdx + 2; i < buf.length(); i++) { + feedChar(buf.charAt(i)); + } + return true; + } + } + if (buf.matches("^\\d+\\.$")) { + return false; // 等空格 + } + // 普通文本 + pendingBuf.setLength(0); + for (char c : buf.toCharArray()) { + emitChar(c); + } + return true; + } + + return false; + } + + // ═══════════════════════════════════════════════════════════ + // 代码块处理 + // ═══════════════════════════════════════════════════════════ + + private final StringBuilder codeBlockCloseBuf = new StringBuilder(); + + private void handleCodeBlock(char ch) { + // 检测代码块结束标记 ``` + if (ch == '`') { + codeBlockCloseBuf.append(ch); + if (codeBlockCloseBuf.length() == 3) { + // 代码块结束 + codeBlockCloseBuf.setLength(0); + output.append(RESET); + output.flushLine(); + output.append(" " + C_CODE_BORDER + "└" + repeatChar('─', 40) + RESET); + output.flushLine(); + state = State.NORMAL; + atLineStart = true; + lineCharCount = 0; + } + return; + } + + // 不是 ` — 先输出之前累积的 ` 字符 + if (codeBlockCloseBuf.length() > 0) { + String partial = codeBlockCloseBuf.toString(); + codeBlockCloseBuf.setLength(0); + for (char c : partial.toCharArray()) { + outputCodeBlockChar(c); + } + } + + if (ch == '\n') { + // 代码块中的换行 + if (codeBlockLang != null && !codeBlockLang.isEmpty()) { + // 第一个换行 — 输出代码块头 + output.flushLine(); + output.append(" " + C_CODE_BORDER + "┌" + repeatChar('─', 30) + + " " + C_CODE_LANG + codeBlockLang + " " + C_CODE_BORDER + repeatChar('─', 9) + RESET); + output.flushLine(); + codeBlockLang = null; + atLineStart = true; + lineCharCount = 0; + return; + } + if (codeBlockLang != null) { + // 空语言名 — 输出无语言标签的代码头 + output.flushLine(); + output.append(" " + C_CODE_BORDER + "┌" + repeatChar('─', 40) + RESET); + output.flushLine(); + codeBlockLang = null; + atLineStart = true; + lineCharCount = 0; + return; + } + // 正常代码行换行 + if (lineCharCount == 0) { + // 空行:仍需输出 │ 保持边框连续 + output.append(" " + C_CODE_BORDER + "│" + RESET); + } + output.append(RESET); + output.flushLine(); + atLineStart = true; + lineCharCount = 0; + return; + } + + // 收集语言名(在第一个换行之前) + if (codeBlockLang != null) { + codeBlockLang += ch; + return; + } + + outputCodeBlockChar(ch); + } + + private void outputCodeBlockChar(char ch) { + if (atLineStart || lineCharCount == 0) { + output.append(" " + C_CODE_BORDER + "│ " + RESET + C_CODE_BLOCK); + atLineStart = false; // ← 关键修复:设置为 false,后续字符不再加 │ + lineCharCount = 1; + } + output.append(String.valueOf(ch)); + } + + private int lineCharCount = 0; + + // ═══════════════════════════════════════════════════════════ + // 换行处理 + // ═══════════════════════════════════════════════════════════ + + private void handleNewline() { + // 所有非代码块的行内状态都在换行时强制关闭 + // 否则 IN_BOLD/IN_ITALIC 等会泄漏到下一行,导致标题等行首检测失败 + switch (state) { + case IN_HEADER: + case IN_BLOCKQUOTE: + case IN_CODE_INLINE: + case IN_STRIKETHROUGH: + case IN_BOLD: + case IN_ITALIC: + output.append(RESET); + state = State.NORMAL; + break; + case IN_CODE_BLOCK: + // 代码块不重置 — 由 ``` 关闭标记处理 + break; + default: + break; + } + // 表格行结束 + if (inTableRow) { + inTableRow = false; + if (isTableDivider) { + isTableDivider = false; + isFirstTableRow = false; + } + } + output.flushLine(); + atLineStart = true; + lineCharCount = 0; + } + + // ═══════════════════════════════════════════════════════════ + // 字符输出 + // ═══════════════════════════════════════════════════════════ + + private void emitChar(char ch) { + if (ch == '\n') { + handleNewline(); + return; + } + if (ch == '\r') { + return; + } + + if (atLineStart && state != State.IN_CODE_BLOCK) { + output.append(" "); + atLineStart = false; + } + + // 表格行内的 | 用边框色 + if (inTableRow && ch == '|') { + output.append(C_TABLE_BORDER + "│" + RESET); + lineCharCount++; + return; + } + // 表格分隔行检测: |---| 中的 - 和 : + if (inTableRow && (ch == '-' || ch == ':')) { + isTableDivider = true; + output.append(C_TABLE_BORDER + String.valueOf(ch) + RESET); + lineCharCount++; + return; + } + + output.append(String.valueOf(ch)); + lineCharCount++; + } + + /** 刷新所有缓冲 — 回合结束、thinking 结束等时机调用 */ + public void flush() { + // 刷出未决缓冲 + if (pendingBuf.length() > 0) { + String text = pendingBuf.toString(); + pendingBuf.setLength(0); + for (char c : text.toCharArray()) { + emitChar(c); + } + } + if (codeBlockCloseBuf.length() > 0) { + String text = codeBlockCloseBuf.toString(); + codeBlockCloseBuf.setLength(0); + for (char c : text.toCharArray()) { + emitChar(c); + } + } + // 重置样式 + if (state != State.NORMAL) { + output.append(RESET); + state = State.NORMAL; + } + output.flushLine(); + lineCharCount = 0; + } + + // ═══════════════════════════════════════════════════════════ + // 工具方法 + // ═══════════════════════════════════════════════════════════ + + private static String repeatChar(char ch, int count) { + StringBuilder sb = new StringBuilder(count); + for (int i = 0; i < count; i++) { + sb.append(ch); + } + return sb.toString(); + } +} diff --git a/soloncode-cli/src/main/java/org/noear/solon/bot/codecli/portal/ui/SlashCommandCompleter.java b/soloncode-cli/src/main/java/org/noear/solon/bot/codecli/portal/ui/SlashCommandCompleter.java new file mode 100644 index 0000000000000000000000000000000000000000..fa1d1045aee58ef1c9164c00ae294d44cffad886 --- /dev/null +++ b/soloncode-cli/src/main/java/org/noear/solon/bot/codecli/portal/ui/SlashCommandCompleter.java @@ -0,0 +1,63 @@ +/* + * 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.bot.codecli.portal.ui; + +import org.jline.reader.Candidate; +import org.jline.reader.Completer; +import org.jline.reader.LineReader; +import org.jline.reader.ParsedLine; + +import java.util.List; + +/** + * 斜杠命令补全器 + * + *

+ * 当用户输入以 "/" 开头时,自动列出匹配的命令候选项。 + * 支持 Tab 补全和 JLine 的 AUTO_MENU_LIST 下拉菜单。 + *

+ * + * @author noear + * @since 0.0.19 + */ +public class SlashCommandCompleter implements Completer { + + private final CommandRegistry registry; + + public SlashCommandCompleter(CommandRegistry registry) { + this.registry = registry; + } + + @Override + public void complete(LineReader reader, ParsedLine line, List candidates) { + String buffer = line.line(); + // 仅在行首输入且以 "/" 开头时触发命令补全 + if (buffer != null && buffer.startsWith("/")) { + List matched = registry.findCandidates(buffer.trim()); + for (CommandRegistry.Command cmd : matched) { + candidates.add(new Candidate( + cmd.getName(), // value - 补全后的实际值 + cmd.getName(), // display - 菜单中显示的文本 + null, // group + cmd.getDescription(), // descr - 显示在右侧的描述 + null, // suffix + null, // key + true // complete - 是否为完整匹配 + )); + } + } + } +} diff --git a/soloncode-cli/src/main/java/org/noear/solon/bot/codecli/portal/ui/StatusBar.java b/soloncode-cli/src/main/java/org/noear/solon/bot/codecli/portal/ui/StatusBar.java new file mode 100644 index 0000000000000000000000000000000000000000..11f6f533e62ff37b15c7feb83d22040994b7b891 --- /dev/null +++ b/soloncode-cli/src/main/java/org/noear/solon/bot/codecli/portal/ui/StatusBar.java @@ -0,0 +1,590 @@ +/* + * 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.bot.codecli.portal.ui; + +import org.jline.terminal.Attributes; +import org.jline.terminal.Terminal; +import org.jline.utils.AttributedString; +import org.jline.utils.AttributedStringBuilder; +import org.jline.utils.AttributedStyle; +import org.jline.utils.Status; + +import java.util.*; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; + +/** + * 底部固定状态栏 — 基于 JLine3 Status API + * + *

+ * 利用 {@link org.jline.utils.Status} 实现终端底部固定行, + * 不随滚动消失,不与 printAbove / prompt 冲突。 + *

+ * + * @author noear + * @since 0.0.19 + */ +public class StatusBar { + private final Terminal terminal; + private volatile Status status; + + // ── 可选字段定义 ── + static final String[] ALL_FIELDS = { + "model", "status", "time", "tokens", "dir", + "version", "session", "turns", "mode", "context" + }; + static final String[] FIELD_DESCRIPTIONS = { + "当前模型名", "状态 + 持续时长", "任务总时长", + "Token 用量", "工作目录", "CLI 版本号", + "会话 ID", "对话轮次", "精简/详细模式", "上下文窗口占用" + }; + + // ── 配置 ── + private final Set enabledFields = new LinkedHashSet<>( + Arrays.asList("model", "status", "time", "tokens", "dir")); + + // ── 动态状态 ── + private volatile String currentStatus = "idle"; + private volatile long taskStartTime = 0; + private volatile long stateStartTime = 0; + private volatile long lastTokens = 0; + + // ── 静态数据 ── + private String modelName = "unknown"; + private String workDir = ""; + private String version = ""; + private String sessionId = ""; + private int turns = 0; + private boolean compactMode = false; + + // ── 底部状态栏动效 ── + private volatile int glowPosition = 0; + private final ScheduledExecutorService barAnimExecutor = Executors.newSingleThreadScheduledExecutor(r -> { + Thread t = new Thread(r, "statusbar-anim"); + t.setDaemon(true); + return t; + }); + private volatile ScheduledFuture barAnimTask; + + // ── 样式常量 ── + private static final AttributedStyle STYLE_TEXT = AttributedStyle.DEFAULT + .foreground(243, 245, 247); + private static final AttributedStyle STYLE_MUTED = AttributedStyle.DEFAULT + .foreground(114, 123, 137); + private static final AttributedStyle STYLE_SEP = AttributedStyle.DEFAULT + .foreground(60, 65, 75); + private static final AttributedStyle STYLE_GREEN = AttributedStyle.DEFAULT + .foreground(39, 201, 63); + private static final AttributedStyle STYLE_WARN = AttributedStyle.DEFAULT + .foreground(232, 194, 122); + private static final AttributedStyle STYLE_BG = AttributedStyle.DEFAULT + .foreground(160, 168, 184).background(22, 27, 36); + + // ── 旧版 ANSI 常量(仅 configUI 使用)── + private static final String ACCENT_BOLD = "\033[1;38;2;255;125;144m", + ACCENT = "\033[38;2;255;125;144m", + TEXT = "\033[38;2;243;245;247m", + MUTED = "\033[38;2;114;123;137m", + DIM = "\033[2m", + GREEN = "\033[38;2;39;201;63m", + RESET = "\033[0m"; + + public StatusBar(Terminal terminal) { + this.terminal = terminal; + } + + // ═══════════════════════════════════════════════════════════ + // 初始化 + // ═══════════════════════════════════════════════════════════ + + public void setModelName(String name) { + this.modelName = name; + } + + public void setWorkDir(String dir) { + this.workDir = dir; + } + + public void setVersion(String ver) { + this.version = ver; + } + + public void setSessionId(String id) { + this.sessionId = id; + } + + public void setCompactMode(boolean compact) { + this.compactMode = compact; + draw(); + } + + public void incrementTurns() { + this.turns++; + } + + // ═══════════════════════════════════════════════════════════ + // JLine Status 生命周期 + // ═══════════════════════════════════════════════════════════ + + /** 初始化底部状态栏 */ + public void setup() { + try { + this.status = Status.getStatus(terminal); + } catch (Exception e) { + // 某些终端可能不支持 Status + this.status = null; + } + } + + /** 绘制状态栏到终端底部 */ + public void draw() { + if (status == null) + return; + + synchronized (terminal) { + try { + List lines = new ArrayList<>(); + lines.add(buildStatusLine()); + terminal.writer().print("\033[?25l"); + terminal.flush(); + status.update(lines); + terminal.writer().print("\033[?25h"); + terminal.flush(); + } catch (Exception ignored) { + try { + terminal.writer().print("\033[?25h"); + terminal.flush(); + } catch (Exception e) { + } + } + } + } + + /** 隐藏状态栏(配置 UI 等场景) */ + public void suspend() { + if (status != null) { + try { + status.suspend(); + } catch (Exception ignored) { + } + } + } + + /** 恢复状态栏显示 */ + public void restore() { + if (status != null) { + try { + status.restore(); + draw(); + } catch (Exception ignored) { + } + } + } + + // ═══════════════════════════════════════════════════════════ + // 状态更新 + // ═══════════════════════════════════════════════════════════ + + /** 更新状态并重绘 */ + public void updateStatus(String status) { + if (!status.equals(this.currentStatus)) { + this.stateStartTime = System.currentTimeMillis(); + } + this.currentStatus = status; + draw(); + } + + /** 获取当前状态文本 */ + public String getStatusText() { + boolean isIdle = "idle".equals(currentStatus); + if (isIdle) { + return "idle"; + } + long elapsed = System.currentTimeMillis() - stateStartTime; + return currentStatus + " " + formatDuration(elapsed); + } + + /** 获取任务总时长文本 */ + public String getTaskTimeText() { + if (taskStartTime == 0) { + return ""; + } + long elapsed = System.currentTimeMillis() - taskStartTime; + return formatDuration(elapsed); + } + + /** 任务开始 */ + public void taskStart() { + this.taskStartTime = System.currentTimeMillis(); + this.stateStartTime = System.currentTimeMillis(); + this.lastTokens = 0; + this.currentStatus = "⚙ thinking"; + startBarAnimation(); + draw(); + } + + /** 任务结束 */ + public void taskEnd(long tokens) { + this.lastTokens = tokens; + this.currentStatus = "idle"; + this.stateStartTime = System.currentTimeMillis(); + stopBarAnimation(); + draw(); + } + + /** 更新 tokens */ + public void updateTokens(long tokens) { + this.lastTokens = tokens; + draw(); + } + + public boolean isIdle() { + return "idle".equals(currentStatus); + } + + // ═══════════════════════════════════════════════════════════ + // 状态栏渲染 + // ═══════════════════════════════════════════════════════════ + + /** 构建一行 AttributedString 状态栏 */ + private AttributedString buildStatusLine() { + int termWidth = terminal.getWidth(); + AttributedStringBuilder sb = new AttributedStringBuilder(); + + // 左侧留一个空格 + sb.styled(STYLE_MUTED, " "); + + // 各段之间用分隔符 + String sep = " │ "; + boolean first = true; + int glowStart = -1, glowEnd = -1; // 模型+状态的字符范围(扫光区域) + + for (String field : enabledFields) { + // 跳过无数据的可选段 + if ("time".equals(field) && taskStartTime == 0) + continue; + if ("tokens".equals(field) && lastTokens == 0) + continue; + if ("context".equals(field)) + continue; + + if (!first) { + sb.styled(STYLE_SEP, sep); + } + first = false; + + switch (field) { + case "model": + glowStart = sb.toAttributedString().length(); // 扫光从 model 开始 + sb.styled(STYLE_TEXT.bold(), modelName); + break; + case "status": + appendStatusSegment(sb); + glowEnd = sb.toAttributedString().length(); // 扫光到 status 结束 + break; + case "time": + long elapsed = System.currentTimeMillis() - taskStartTime; + sb.styled(STYLE_MUTED, "⏱ "); + sb.styled(STYLE_TEXT, formatDuration(elapsed)); + break; + case "tokens": + sb.styled(STYLE_MUTED, "↑ "); + sb.styled(STYLE_TEXT, String.valueOf(lastTokens)); + break; + case "dir": + int usedWidth = sb.toAttributedString().columnLength(); + int sepWidth = sep.length(); + int availForDir = termWidth - usedWidth - sepWidth - 2; + if (availForDir > 10) { + String displayDir = shortenPath(workDir, availForDir); + sb.styled(STYLE_MUTED, displayDir); + } + break; + case "version": + sb.styled(STYLE_MUTED, version); + break; + case "session": + sb.styled(STYLE_MUTED, "⊙ "); + sb.styled(STYLE_TEXT, sessionId); + break; + case "turns": + sb.styled(STYLE_MUTED, "#"); + sb.styled(STYLE_TEXT, String.valueOf(turns)); + break; + case "mode": + sb.styled(STYLE_TEXT, compactMode ? "compact" : "verbose"); + break; + } + } + + // 填充到终端宽度 + AttributedString line = sb.toAttributedString(); + int currentLen = line.columnLength(); + if (currentLen < termWidth) { + sb.styled(STYLE_BG, repeat(' ', termWidth - currentLen)); + line = sb.toAttributedString(); + } else if (currentLen > termWidth) { + line = line.columnSubSequence(0, termWidth); + } + + // 非 idle 状态:对 model+status 段应用扫光 + if (!"idle".equals(currentStatus) && glowStart >= 0 && glowEnd > glowStart) { + line = applyGlow(line, glowStart, glowEnd); + } + + return line; + } + + /** 对指定范围应用扫光效果(只改 status 段,其他段原样保留) */ + private AttributedString applyGlow(AttributedString line, int rangeStart, int rangeEnd) { + int len = line.length(); + // 安全边界 + rangeStart = Math.max(0, Math.min(rangeStart, len)); + rangeEnd = Math.max(rangeStart, Math.min(rangeEnd, len)); + int rangeLen = rangeEnd - rangeStart; + int glow = rangeStart + (glowPosition % (rangeLen + 6)); + int glowRadius = 3; + + AttributedStringBuilder result = new AttributedStringBuilder(); + + // ① 原样保留 status 段前面的内容(model + 分隔符等) + if (rangeStart > 0) { + result.append(line, 0, rangeStart); + } + + // ② 对 status 段逐字符应用扫光 + for (int i = rangeStart; i < rangeEnd; i++) { + char ch = line.charAt(i); + int dist = Math.abs(i - glow); + if (dist <= glowRadius) { + float factor = 1.0f - (dist / (float) (glowRadius + 1)); + int r = Math.min(255, (int) (160 + 95 * factor)); + int g = Math.min(255, (int) (168 + 87 * factor)); + int b = Math.min(255, (int) (184 + 71 * factor)); + result.styled(AttributedStyle.DEFAULT.foreground(r, g, b).background(22, 27, 36), + String.valueOf(ch)); + } else { + result.append(line, i, i + 1); + } + } + + // ③ 原样保留 status 段后面的内容(tokens, dir 等) + if (rangeEnd < len) { + result.append(line, rangeEnd, len); + } + + return result.toAttributedString(); + } + + /** 启动底部状态栏扫光动效 */ + private void startBarAnimation() { + stopBarAnimation(); + glowPosition = 0; + barAnimTask = barAnimExecutor.scheduleAtFixedRate(() -> { + try { + glowPosition++; + draw(); + } catch (Exception ignored) { + } + }, 100, 100, TimeUnit.MILLISECONDS); + } + + /** 停止底部状态栏扫光动效 */ + private void stopBarAnimation() { + ScheduledFuture task = barAnimTask; + if (task != null) { + task.cancel(false); + barAnimTask = null; + } + } + + /** 渲染状态段(idle 绿色,运行中显示动态描述) */ + private void appendStatusSegment(AttributedStringBuilder sb) { + boolean isIdle = "idle".equals(currentStatus); + if (isIdle) { + sb.styled(STYLE_GREEN, "● idle"); + } else { + long elapsed = System.currentTimeMillis() - stateStartTime; + sb.styled(STYLE_WARN, currentStatus); + sb.styled(STYLE_MUTED, " " + formatDuration(elapsed)); + } + } + + // ═══════════════════════════════════════════════════════════ + // /statusbar 交互式配置 + // ═══════════════════════════════════════════════════════════ + + /** 显示交互式配置 UI */ + public void showConfigUI() { + suspend(); // 暂停底部状态栏,避免与配置菜单冲突 + + Attributes savedAttrs = terminal.getAttributes(); + try { + terminal.enterRawMode(); + Set tempEnabled = new LinkedHashSet<>(enabledFields); + int cursor = 0; + + drawConfigMenu(tempEnabled, cursor); + + while (true) { + int key = readKey(); + + if (key == -1) { + break; + } else if (key == 27) { + try { + Thread.sleep(50); + } catch (InterruptedException ignored) { + } + if (isReaderReady()) { + int next = readKey(); + if (next == '[' || next == 'O') { + if (isReaderReady()) { + int arrow = readKey(); + if (arrow == 'A') { + cursor = Math.max(0, cursor - 1); + drawConfigMenu(tempEnabled, cursor); + continue; + } else if (arrow == 'B') { + cursor = Math.min(ALL_FIELDS.length - 1, cursor + 1); + drawConfigMenu(tempEnabled, cursor); + continue; + } + } + } + } + break; + } else if (key == ' ') { + String field = ALL_FIELDS[cursor]; + if (tempEnabled.contains(field)) { + tempEnabled.remove(field); + } else { + tempEnabled.add(field); + } + drawConfigMenu(tempEnabled, cursor); + } else if (key == 'k' || key == 'K') { + cursor = Math.max(0, cursor - 1); + drawConfigMenu(tempEnabled, cursor); + } else if (key == 'j' || key == 'J') { + cursor = Math.min(ALL_FIELDS.length - 1, cursor + 1); + drawConfigMenu(tempEnabled, cursor); + } else if (key == '\r' || key == '\n') { + enabledFields.clear(); + enabledFields.addAll(tempEnabled); + break; + } + } + } finally { + terminal.setAttributes(savedAttrs); + } + + clearConfigMenu(); + restore(); // 恢复底部状态栏 + } + + private void drawConfigMenu(Set tempEnabled, int cursor) { + StringBuilder sb = new StringBuilder(); + sb.append("\r\033[J"); + + sb.append("\n"); + sb.append(MUTED + " ─── " + RESET + TEXT + "Status Bar 配置" + RESET + MUTED + " ─── " + RESET); + sb.append(MUTED + " ↑↓/jk Space Enter Esc" + RESET); + sb.append("\n\n"); + + for (int i = 0; i < ALL_FIELDS.length; i++) { + String field = ALL_FIELDS[i]; + boolean enabled = tempEnabled.contains(field); + boolean isCursor = (i == cursor); + + String check = enabled ? GREEN + "[✔]" + RESET : MUTED + "[ ]" + RESET; + String name = String.format("%-10s", capitalize(field)); + String desc = MUTED + FIELD_DESCRIPTIONS[i] + RESET; + + if (isCursor) { + sb.append(" " + ACCENT + "▸ " + RESET + check + " " + ACCENT_BOLD + name + RESET + " " + desc); + } else { + String nameColor = enabled ? TEXT : MUTED; + sb.append(" " + check + " " + nameColor + name + RESET + " " + desc); + } + sb.append("\n"); + } + + sb.append("\033[" + (ALL_FIELDS.length + 3) + "A"); + + terminal.writer().write(sb.toString()); + terminal.flush(); + } + + private void clearConfigMenu() { + terminal.writer().write("\r\033[J"); + terminal.flush(); + } + + private int readKey() { + try { + return terminal.reader().read(); + } catch (Exception e) { + return -1; + } + } + + private boolean isReaderReady() { + try { + return terminal.reader().ready(); + } catch (Exception e) { + return false; + } + } + + // ═══════════════════════════════════════════════════════════ + // 工具方法 + // ═══════════════════════════════════════════════════════════ + + static String formatDuration(long millis) { + double seconds = millis / 1000.0; + if (seconds < 60) { + return String.format("%.1fs", seconds); + } + int mins = (int) (seconds / 60); + double secs = seconds % 60; + return String.format("%dm%.0fs", mins, secs); + } + + /** 缩短路径显示(保留头尾) */ + private static String shortenPath(String path, int maxLen) { + if (path == null || path.length() <= maxLen) + return path; + int headLen = maxLen / 3; + int tailLen = maxLen - headLen - 3; + return path.substring(0, headLen) + "..." + path.substring(path.length() - tailLen); + } + + private static String repeat(char c, int count) { + if (count <= 0) + return ""; + char[] chars = new char[count]; + Arrays.fill(chars, c); + return new String(chars); + } + + private static String capitalize(String s) { + if (s == null || s.isEmpty()) + return s; + return Character.toUpperCase(s.charAt(0)) + s.substring(1); + } +}