diff options
| -rw-r--r-- | .gitignore | 2 | ||||
| -rw-r--r-- | bot.go | 166 | ||||
| -rwxr-xr-x | cli-tests/sort-img/check.sh | 74 | ||||
| -rwxr-xr-x | cli-tests/sort-img/run.sh | 25 | ||||
| -rwxr-xr-x | cli-tests/sort-img/setup.sh | 9 | ||||
| -rw-r--r-- | cli-tests/sort-img/task.txt | 2 | ||||
| -rwxr-xr-x | cli-tests/sort-img/teardown.sh | 4 | ||||
| -rwxr-xr-x | cli-tests/sort-text/check.sh | 91 | ||||
| -rwxr-xr-x | cli-tests/sort-text/run.sh | 25 | ||||
| -rwxr-xr-x | cli-tests/sort-text/setup.sh | 10 | ||||
| -rw-r--r-- | cli-tests/sort-text/task.txt | 2 | ||||
| -rwxr-xr-x | cli-tests/sort-text/teardown.sh | 4 | ||||
| -rw-r--r-- | config.example.toml | 2 | ||||
| -rw-r--r-- | config/config.go | 2 | ||||
| -rw-r--r-- | main.go | 255 | ||||
| -rw-r--r-- | tools/chain.go | 131 | ||||
| -rw-r--r-- | tools/tools.go | 10 | ||||
| -rw-r--r-- | tui.go | 1 |
18 files changed, 758 insertions, 57 deletions
@@ -1,4 +1,3 @@ -*.txt *.json testlog history/ @@ -18,3 +17,4 @@ chat_exports/*.json ragimport .env onnx/ +*.log @@ -25,6 +25,8 @@ import ( "sync" "sync/atomic" "time" + + "github.com/rivo/tview" ) var ( @@ -46,7 +48,63 @@ var ( chunkParser ChunkParser lastToolCall *models.FuncCall lastRespStats *models.ResponseStats - //nolint:unused // TTS_ENABLED conditionally uses this + + outputHandler OutputHandler + cliPrevOutput string + cliRespDone chan bool +) + +type OutputHandler interface { + Write(p string) + Writef(format string, args ...interface{}) + ScrollToEnd() +} + +type TUIOutputHandler struct { + tv *tview.TextView +} + +func (h *TUIOutputHandler) Write(p string) { + if h.tv != nil { + fmt.Fprint(h.tv, p) + } + if cfg != nil && cfg.CLIMode { + fmt.Print(p) + cliPrevOutput = p + } +} + +func (h *TUIOutputHandler) Writef(format string, args ...interface{}) { + s := fmt.Sprintf(format, args...) + if h.tv != nil { + fmt.Fprint(h.tv, s) + } + if cfg != nil && cfg.CLIMode { + fmt.Print(s) + cliPrevOutput = s + } +} + +func (h *TUIOutputHandler) ScrollToEnd() { + if h.tv != nil { + h.tv.ScrollToEnd() + } +} + +type CLIOutputHandler struct{} + +func (h *CLIOutputHandler) Write(p string) { + fmt.Print(p) +} + +func (h *CLIOutputHandler) Writef(format string, args ...interface{}) { + fmt.Printf(format, args...) +} + +func (h *CLIOutputHandler) ScrollToEnd() { +} + +var ( basicCard = &models.CharCard{ ID: models.ComputeCardID("assistant", "basic_sys"), SysPrompt: models.BasicSysMsg, @@ -800,6 +858,10 @@ func chatWatcher(ctx context.Context) { // inpired by https://github.com/rivo/tview/issues/225 func showSpinner() { + if cfg.CLIMode { + showSpinnerCLI() + return + } spinners := []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"} var i int botPersona := cfg.AssistantRole @@ -826,6 +888,12 @@ func showSpinner() { }) } +func showSpinnerCLI() { + for botRespMode.Load() || toolRunningMode.Load() { + time.Sleep(400 * time.Millisecond) + } +} + func chatRound(r *models.ChatRoundReq) error { interruptResp.Store(false) botRespMode.Store(true) @@ -858,13 +926,22 @@ func chatRound(r *models.ChatRoundReq) error { Role: botPersona, Content: "", }) nl := "\n\n" - prevText := textView.GetText(true) - if strings.HasSuffix(prevText, nl) { - nl = "" - } else if strings.HasSuffix(prevText, "\n") { - nl = "\n" + prevText := cliPrevOutput + if cfg.CLIMode { + if strings.HasSuffix(prevText, nl) { + nl = "" + } else if strings.HasSuffix(prevText, "\n") { + nl = "\n" + } + } else { + prevText = textView.GetText(true) + if strings.HasSuffix(prevText, nl) { + nl = "" + } else if strings.HasSuffix(prevText, "\n") { + nl = "\n" + } } - fmt.Fprintf(textView, "%s[-:-:b](%d) %s[-:-:-]\n", nl, msgIdx, roleToIcon(botPersona)) + outputHandler.Writef("%s[-:-:b](%d) %s[-:-:-]\n", nl, msgIdx, roleToIcon(botPersona)) } else { msgIdx = len(chatBody.Messages) - 1 } @@ -886,9 +963,9 @@ out: thinkingBuffer.WriteString(chunk) if thinkingCollapsed { // Show placeholder immediately when thinking starts in collapsed mode - fmt.Fprint(textView, "[yellow::i][thinking... (press Alt+T to expand)][-:-:-]") + outputHandler.Write("[yellow::i][thinking... (press Alt+T to expand)][-:-:-]") if cfg.AutoScrollEnabled { - textView.ScrollToEnd() + outputHandler.ScrollToEnd() } respText.WriteString(chunk) continue @@ -903,7 +980,7 @@ out: respText.WriteString(chunk) justExitedThinkingCollapsed = true if cfg.AutoScrollEnabled { - textView.ScrollToEnd() + outputHandler.ScrollToEnd() } continue } @@ -920,32 +997,32 @@ out: chunk = "\n\n" + chunk justExitedThinkingCollapsed = false } - fmt.Fprint(textView, chunk) + outputHandler.Write(chunk) respText.WriteString(chunk) // Update the message in chatBody.Messages so it persists during Alt+T if !r.Resume { chatBody.Messages[msgIdx].Content += respText.String() } if cfg.AutoScrollEnabled { - textView.ScrollToEnd() + outputHandler.ScrollToEnd() } // Send chunk to audio stream handler if cfg.TTS_ENABLED { TTSTextChan <- chunk } case toolChunk := <-openAIToolChan: - fmt.Fprint(textView, toolChunk) + outputHandler.Write(toolChunk) toolResp.WriteString(toolChunk) if cfg.AutoScrollEnabled { - textView.ScrollToEnd() + outputHandler.ScrollToEnd() } case <-streamDone: for len(chunkChan) > 0 { chunk := <-chunkChan - fmt.Fprint(textView, chunk) + outputHandler.Write(chunk) respText.WriteString(chunk) if cfg.AutoScrollEnabled { - textView.ScrollToEnd() + outputHandler.ScrollToEnd() } if cfg.TTS_ENABLED { TTSTextChan <- chunk @@ -987,8 +1064,7 @@ out: cleanChatBody() refreshChatDisplay() updateStatusLine() - // bot msg is done; - // now check it for func call + // bot msg is done; now check it for func call // logChat(activeChatName, chatBody.Messages) if err := updateStorageChat(activeChatName, chatBody.Messages); err != nil { logger.Warn("failed to update storage", "error", err, "name", activeChatName) @@ -999,8 +1075,16 @@ out: return nil } if findCall(respTextNoThink, toolResp.String()) { + // Tool was found and executed, subsequent chatRound will signal cliRespDone when complete return nil } + // No tool call - signal completion now + if cfg.CLIMode && cliRespDone != nil { + select { + case cliRespDone <- true: + default: + } + } // Check if this message was sent privately to specific characters // If so, trigger those characters to respond if that char is not controlled by user // perhaps we should have narrator role to determine which char is next to act @@ -1229,7 +1313,7 @@ func findCall(msg, toolCall string) bool { // return true // } // Show tool call progress indicator before execution - fmt.Fprintf(textView, "\n[yellow::i][tool: %s...][-:-:-]", fc.Name) + outputHandler.Writef("\n[yellow::i][tool: %s...][-:-:-]", fc.Name) toolRunningMode.Store(true) resp, okT := tools.CallToolWithAgent(fc.Name, fc.Args) if !okT { @@ -1307,7 +1391,7 @@ func findCall(msg, toolCall string) bool { IsShellCommand: isShellCommand, } } - fmt.Fprintf(textView, "%s[-:-:b](%d) <%s>: [-:-:-]\n%s\n", + outputHandler.Writef("%s[-:-:b](%d) <%s>: [-:-:-]\n%s\n", "\n\n", len(chatBody.Messages), cfg.ToolRole, toolResponseMsg.GetText()) chatBody.Messages = append(chatBody.Messages, toolResponseMsg) // Clear the stored tool call ID after using it @@ -1500,6 +1584,31 @@ func refreshLocalModelsIfEmpty() { localModelsMu.Unlock() } +func startNewCLIChat() []models.RoleMsg { + id, err := store.ChatGetMaxID() + if err != nil { + logger.Error("failed to get chat id", "error", err) + } + id++ + charToStart(cfg.AssistantRole, false) + newChat := &models.Chat{ + ID: id, + Name: fmt.Sprintf("%d_%s", id, cfg.AssistantRole), + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + Msgs: "", + Agent: cfg.AssistantRole, + } + activeChatName = newChat.Name + chatMap[newChat.Name] = newChat + cliPrevOutput = "" + return chatBody.Messages +} + +func startNewCLIErrors() []models.RoleMsg { + return startNewCLIChat() +} + func summarizeAndStartNewChat() { if len(chatBody.Messages) == 0 { showToast("info", "No chat history to summarize") @@ -1526,8 +1635,10 @@ func summarizeAndStartNewChat() { } chatBody.Messages = append(chatBody.Messages, toolMsg) // Update UI - textView.SetText(chatToText(chatBody.Messages, cfg.ShowSys)) - colorText() + if !cfg.CLIMode { + textView.SetText(chatToText(chatBody.Messages, cfg.ShowSys)) + colorText() + } // Update storage if err := updateStorageChat(activeChatName, chatBody.Messages); err != nil { logger.Warn("failed to update storage after injecting summary", "error", err) @@ -1585,7 +1696,12 @@ func init() { return } lastToolCall = &models.FuncCall{} - lastChat := loadOldChatOrGetNew() + var lastChat []models.RoleMsg + if cfg.CLIMode { + lastChat = startNewCLIErrors() + } else { + lastChat = loadOldChatOrGetNew() + } chatBody = &models.ChatBody{ Model: "modelname", Stream: true, @@ -1620,7 +1736,9 @@ func init() { // atomic default values cachedModelColor.Store("orange") go chatWatcher(ctx) - initTUI() + if !cfg.CLIMode { + initTUI() + } tools.InitTools(cfg, logger, store) // tooler = tools.InitTools(cfg, logger, store) // tooler.RegisterWindowTools(modelHasVision) diff --git a/cli-tests/sort-img/check.sh b/cli-tests/sort-img/check.sh new file mode 100755 index 0000000..3efc9d2 --- /dev/null +++ b/cli-tests/sort-img/check.sh @@ -0,0 +1,74 @@ +#!/bin/bash +set -e + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +LOG_FILE=$(ls -t "$SCRIPT_DIR"/*_run.log 2>/dev/null | head -1) + +PASS=0 +FAIL=0 + +log_pass() { + echo "[PASS] $1" + PASS=$((PASS + 1)) +} + +log_fail() { + echo "[FAIL] $1" + FAIL=$((FAIL + 1)) +} + +echo "=== Checking results ===" +echo "" + +# Check has-animals directory exists +if [ -d "/tmp/sort-img/has-animals" ]; then + log_pass "has-animals directory exists" +else + log_fail "has-animals directory missing" +fi + +# Check no-animals directory exists +if [ -d "/tmp/sort-img/no-animals" ]; then + log_pass "no-animals directory exists" +else + log_fail "no-animals directory missing" +fi + +# Check has-animals contains at least one image +HAS_ANIMALS_FILES=$(ls -1 /tmp/sort-img/has-animals 2>/dev/null | wc -l) +if [ "$HAS_ANIMALS_FILES" -gt 0 ]; then + log_pass "has-animals contains images ($HAS_ANIMALS_FILES files)" +else + log_fail "has-animals is empty" +fi + +# Check no-animals contains at least one image +NO_ANIMALS_FILES=$(ls -1 /tmp/sort-img/no-animals 2>/dev/null | wc -l) +if [ "$NO_ANIMALS_FILES" -gt 0 ]; then + log_pass "no-animals contains images ($NO_ANIMALS_FILES files)" +else + log_fail "no-animals is empty" +fi + +# Check total files sorted correctly (3 original files should be in subdirs) +TOTAL_SORTED=$((HAS_ANIMALS_FILES + NO_ANIMALS_FILES)) +if [ "$TOTAL_SORTED" -eq 3 ]; then + log_pass "all 3 files sorted into subdirectories" +else + log_fail "expected 3 files sorted, got $TOTAL_SORTED" +fi + +echo "" +echo "=== Summary ===" +echo "PASSED: $PASS" +echo "FAILED: $FAIL" + +if [ $FAIL -gt 0 ]; then + echo "" + echo "Log file: $LOG_FILE" + exit 1 +fi + +echo "" +echo "All tests passed!" +exit 0 diff --git a/cli-tests/sort-img/run.sh b/cli-tests/sort-img/run.sh new file mode 100755 index 0000000..5cd5d3e --- /dev/null +++ b/cli-tests/sort-img/run.sh @@ -0,0 +1,25 @@ +#!/bin/bash +set -e + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +TIMESTAMP=$(date +%Y%m%d_%H%M%S) +LOG_FILE="$SCRIPT_DIR/${TIMESTAMP}_run.log" + +exec > "$LOG_FILE" 2>&1 + +echo "=== Running teardown ===" +"$SCRIPT_DIR/teardown.sh" + +echo "" +echo "=== Running setup ===" +"$SCRIPT_DIR/setup.sh" + +echo "" +echo "=== Running task ===" +TASK=$(cat "$SCRIPT_DIR/task.txt") +cd /home/grail/projects/plays/goplays/gf-lt +go run . -cli -msg "$TASK" + +echo "" +echo "=== Done ===" +echo "Log file: $LOG_FILE" diff --git a/cli-tests/sort-img/setup.sh b/cli-tests/sort-img/setup.sh new file mode 100755 index 0000000..6b89be8 --- /dev/null +++ b/cli-tests/sort-img/setup.sh @@ -0,0 +1,9 @@ +#!/bin/sh + +set -e + +mkdir -p /tmp/sort-img + +cp ../../../assets/ex01.png /tmp/sort-img/file1.png +cp ../../../assets/helppage.png /tmp/sort-img/file2.png +cp ../../../assets/yt_thumb.jpg /tmp/sort-img/file3.jpg diff --git a/cli-tests/sort-img/task.txt b/cli-tests/sort-img/task.txt new file mode 100644 index 0000000..ebf6185 --- /dev/null +++ b/cli-tests/sort-img/task.txt @@ -0,0 +1,2 @@ +go to /tmp/sort-img, create directories: has-animals, no-animals +sort images in /tmp/sort-img into created directories by content diff --git a/cli-tests/sort-img/teardown.sh b/cli-tests/sort-img/teardown.sh new file mode 100755 index 0000000..691155b --- /dev/null +++ b/cli-tests/sort-img/teardown.sh @@ -0,0 +1,4 @@ +#!/bin/bash +set -e + +rm -rf /tmp/sort-img diff --git a/cli-tests/sort-text/check.sh b/cli-tests/sort-text/check.sh new file mode 100755 index 0000000..cc1d1df --- /dev/null +++ b/cli-tests/sort-text/check.sh @@ -0,0 +1,91 @@ +#!/bin/bash +set -e + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +LOG_FILE=$(ls -t "$SCRIPT_DIR"/*_run.log 2>/dev/null | head -1) + +PASS=0 +FAIL=0 + +log_pass() { + echo "[PASS] $1" + PASS=$((PASS + 1)) +} + +log_fail() { + echo "[FAIL] $1" + FAIL=$((FAIL + 1)) +} + +echo "=== Checking results ===" +echo "" + +# Check animals directory exists +if [ -d "/tmp/sort-text/animals" ]; then + log_pass "animals directory exists" +else + log_fail "animals directory missing" +fi + +# Check colors directory exists +if [ -d "/tmp/sort-text/colors" ]; then + log_pass "colors directory exists" +else + log_fail "colors directory missing" +fi + +# Check animals contain cat/dog +ANIMALS_FILES=$(ls -1 /tmp/sort-text/animals 2>/dev/null | tr '\n' ' ') +if echo "$ANIMALS_FILES" | grep -q "file1.txt" && echo "$ANIMALS_FILES" | grep -q "file3.txt"; then + log_pass "animals contains animal files" +else + log_fail "animals missing animal files (got: $ANIMALS_FILES)" +fi + +# Check colors contain red/blue +COLORS_FILES=$(ls -1 /tmp/sort-text/colors 2>/dev/null | tr '\n' ' ') +if echo "$COLORS_FILES" | grep -q "file2.txt" && echo "$COLORS_FILES" | grep -q "file4.txt"; then + log_pass "colors contains color files" +else + log_fail "colors missing color files (got: $COLORS_FILES)" +fi + +# Verify content +if grep -q "cat" /tmp/sort-text/animals/file1.txt 2>/dev/null; then + log_pass "file1.txt contains 'cat'" +else + log_fail "file1.txt missing 'cat'" +fi + +if grep -q "dog" /tmp/sort-text/animals/file3.txt 2>/dev/null; then + log_pass "file3.txt contains 'dog'" +else + log_fail "file3.txt missing 'dog'" +fi + +if grep -q "red" /tmp/sort-text/colors/file2.txt 2>/dev/null; then + log_pass "file2.txt contains 'red'" +else + log_fail "file2.txt missing 'red'" +fi + +if grep -q "blue" /tmp/sort-text/colors/file4.txt 2>/dev/null; then + log_pass "file4.txt contains 'blue'" +else + log_fail "file4.txt missing 'blue'" +fi + +echo "" +echo "=== Summary ===" +echo "PASSED: $PASS" +echo "FAILED: $FAIL" + +if [ $FAIL -gt 0 ]; then + echo "" + echo "Log file: $LOG_FILE" + exit 1 +fi + +echo "" +echo "All tests passed!" +exit 0 diff --git a/cli-tests/sort-text/run.sh b/cli-tests/sort-text/run.sh new file mode 100755 index 0000000..5cd5d3e --- /dev/null +++ b/cli-tests/sort-text/run.sh @@ -0,0 +1,25 @@ +#!/bin/bash +set -e + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +TIMESTAMP=$(date +%Y%m%d_%H%M%S) +LOG_FILE="$SCRIPT_DIR/${TIMESTAMP}_run.log" + +exec > "$LOG_FILE" 2>&1 + +echo "=== Running teardown ===" +"$SCRIPT_DIR/teardown.sh" + +echo "" +echo "=== Running setup ===" +"$SCRIPT_DIR/setup.sh" + +echo "" +echo "=== Running task ===" +TASK=$(cat "$SCRIPT_DIR/task.txt") +cd /home/grail/projects/plays/goplays/gf-lt +go run . -cli -msg "$TASK" + +echo "" +echo "=== Done ===" +echo "Log file: $LOG_FILE" diff --git a/cli-tests/sort-text/setup.sh b/cli-tests/sort-text/setup.sh new file mode 100755 index 0000000..351ebe2 --- /dev/null +++ b/cli-tests/sort-text/setup.sh @@ -0,0 +1,10 @@ +#!/bin/sh + +set -e + +mkdir -p /tmp/sort-text + +printf "cat" > /tmp/sort-text/file1.txt +printf "red" > /tmp/sort-text/file2.txt +printf "dog" > /tmp/sort-text/file3.txt +printf "blue" > /tmp/sort-text/file4.txt diff --git a/cli-tests/sort-text/task.txt b/cli-tests/sort-text/task.txt new file mode 100644 index 0000000..0d3a9e8 --- /dev/null +++ b/cli-tests/sort-text/task.txt @@ -0,0 +1,2 @@ +go to /tmp/sort-text, create directories: animals, colors +sort /tmp/sort-text/*.txt into created directories by text content diff --git a/cli-tests/sort-text/teardown.sh b/cli-tests/sort-text/teardown.sh new file mode 100755 index 0000000..5a833ce --- /dev/null +++ b/cli-tests/sort-text/teardown.sh @@ -0,0 +1,4 @@ +#!/bin/bash +set -e + +rm -rf /tmp/sort-text diff --git a/config.example.toml b/config.example.toml index 8e45734..a58729f 100644 --- a/config.example.toml +++ b/config.example.toml @@ -18,7 +18,7 @@ EmbedTokenizerPath = "onnx/embedgemma/tokenizer.json" EmbedDims = 768 # ShowSys = true -LogFile = "log.txt" +LogFile = "log.log" UserRole = "user" ToolRole = "tool" AssistantRole = "assistant" diff --git a/config/config.go b/config/config.go index e8c2687..dc03a1e 100644 --- a/config/config.go +++ b/config/config.go @@ -75,6 +75,8 @@ type Config struct { // playwright browser PlaywrightEnabled bool `toml:"PlaywrightEnabled"` PlaywrightDebug bool `toml:"PlaywrightDebug"` // !headless + // CLI mode + CLIMode bool } func LoadConfig(fn string) (*Config, error) { @@ -1,6 +1,15 @@ package main import ( + "bufio" + "flag" + "fmt" + "gf-lt/models" + "gf-lt/pngmeta" + "os" + "slices" + "strconv" + "strings" "sync/atomic" "github.com/rivo/tview" @@ -22,9 +31,22 @@ var ( statusLineTempl = "help (F12) | chat: [orange:-:b]%s[-:-:-] (F1) | [%s:-:b]tool use[-:-:-] (ctrl+k) | model: [%s:-:b]%s[-:-:-] (ctrl+l) | [%s:-:b]skip LLM resp[-:-:-] (F10) | API: [orange:-:b]%s[-:-:-] (ctrl+v)\nwriting as: [orange:-:b]%s[-:-:-] (ctrl+q) | bot will write as [orange:-:b]%s[-:-:-] (ctrl+x)" focusSwitcher = map[tview.Primitive]tview.Primitive{} app *tview.Application + cliCardPath string + cliContinue bool + cliMsg string ) func main() { + flag.BoolVar(&cfg.CLIMode, "cli", false, "Run in CLI mode without TUI") + flag.BoolVar(&cfg.ToolUse, "tools", true, "run with tools") + flag.StringVar(&cliCardPath, "card", "", "Path to syscard JSON file") + flag.BoolVar(&cliContinue, "continue", false, "Continue from last chat (by agent or card)") + flag.StringVar(&cliMsg, "msg", "", "Send message and exit (one-shot mode)") + flag.Parse() + if cfg.CLIMode { + runCLIMode() + return + } pages.AddPage("main", flex, true, true) if err := app.SetRoot(pages, true).EnableMouse(cfg.EnableMouse).EnablePaste(true).Run(); err != nil { @@ -32,3 +54,236 @@ func main() { return } } + +func runCLIMode() { + outputHandler = &CLIOutputHandler{} + cliRespDone = make(chan bool, 1) + if cliCardPath != "" { + card, err := pngmeta.ReadCardJson(cliCardPath) + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to load syscard: %v\n", err) + os.Exit(1) + } + cfg.AssistantRole = card.Role + sysMap[card.ID] = card + roleToID[card.Role] = card.ID + charToStart(card.Role, false) + fmt.Printf("Loaded syscard: %s (%s)\n", card.Role, card.FilePath) + } + if cliContinue { + if cliCardPath != "" { + history, err := loadAgentsLastChat(cfg.AssistantRole) + if err != nil { + fmt.Printf("No previous chat found for %s, starting new chat\n", cfg.AssistantRole) + startNewCLIChat() + } else { + chatBody.Messages = history + fmt.Printf("Continued chat: %s\n", activeChatName) + } + } else { + chatBody.Messages = loadOldChatOrGetNew() + fmt.Printf("Continued chat: %s\n", activeChatName) + } + } else { + startNewCLIChat() + } + printCLIWelcome() + go func() { + <-ctx.Done() + os.Exit(0) + }() + if cliMsg != "" { + persona := cfg.UserRole + if cfg.WriteNextMsgAs != "" { + persona = cfg.WriteNextMsgAs + } + chatRoundChan <- &models.ChatRoundReq{Role: persona, UserMsg: cliMsg} + <-cliRespDone + fmt.Println() + return + } + scanner := bufio.NewScanner(os.Stdin) + for { + fmt.Print("> ") + if !scanner.Scan() { + break + } + msg := scanner.Text() + if msg == "" { + continue + } + if strings.HasPrefix(msg, "/") { + if !handleCLICommand(msg) { + return + } + fmt.Println() + continue + } + persona := cfg.UserRole + if cfg.WriteNextMsgAs != "" { + persona = cfg.WriteNextMsgAs + } + chatRoundChan <- &models.ChatRoundReq{Role: persona, UserMsg: msg} + <-cliRespDone + fmt.Println() + } +} + +func printCLIWelcome() { + fmt.Println("CLI Mode started. Type your messages or commands.") + fmt.Println("Type /help for available commands.") + fmt.Println() +} + +func printCLIHelp() { + fmt.Println("Available commands:") + fmt.Println(" /help, /h - Show this help message") + fmt.Println(" /new, /n - Start a new chat (clears conversation)") + fmt.Println(" /card <path>, /c <path> - Load a different syscard") + fmt.Println(" /undo, /u - Delete last message") + fmt.Println(" /history, /ls - List chat history") + fmt.Println(" /load <name> - Load a specific chat by name") + fmt.Println(" /model <name>, /m <name> - Switch model") + fmt.Println(" /api <index>, /a <index> - Switch API link (no index to list)") + fmt.Println(" /quit, /q, /exit - Exit CLI mode") + fmt.Println() + fmt.Printf("Current syscard: %s\n", cfg.AssistantRole) + fmt.Printf("Current model: %s\n", chatBody.Model) + fmt.Printf("Current API: %s\n", cfg.CurrentAPI) + fmt.Println() +} + +func handleCLICommand(msg string) bool { + parts := strings.Fields(msg) + cmd := strings.ToLower(parts[0]) + args := parts[1:] + + switch cmd { + case "/help", "/h": + printCLIHelp() + case "/new", "/n": + startNewCLIChat() + fmt.Println("New chat started.") + fmt.Printf("Syscard: %s\n", cfg.AssistantRole) + fmt.Println() + case "/card", "/c": + if len(args) == 0 { + fmt.Println("Usage: /card <path>") + return true + } + card, err := pngmeta.ReadCardJson(args[0]) + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to load syscard: %v\n", err) + return true + } + cfg.AssistantRole = card.Role + sysMap[card.ID] = card + roleToID[card.Role] = card.ID + charToStart(card.Role, false) + startNewCLIChat() + fmt.Printf("Switched to syscard: %s (%s)\n", card.Role, card.FilePath) + case "/undo", "/u": + if len(chatBody.Messages) == 0 { + fmt.Println("No messages to delete.") + return true + } + chatBody.Messages = chatBody.Messages[:len(chatBody.Messages)-1] + cliPrevOutput = "" + fmt.Println("Last message deleted.") + case "/history", "/ls": + fmt.Println("Chat history:") + for name := range chatMap { + marker := " " + if name == activeChatName { + marker = "* " + } + fmt.Printf("%s%s\n", marker, name) + } + fmt.Println() + case "/load": + if len(args) == 0 { + fmt.Println("Usage: /load <name>") + return true + } + name := args[0] + chat, ok := chatMap[name] + if !ok { + fmt.Printf("Chat not found: %s\n", name) + return true + } + history, err := chat.ToHistory() + if err != nil { + fmt.Printf("Failed to load chat: %v\n", err) + return true + } + chatBody.Messages = history + activeChatName = name + cfg.AssistantRole = chat.Agent + fmt.Printf("Loaded chat: %s\n", name) + case "/model", "/m": + getModelListForAPI := func(api string) []string { + if strings.Contains(api, "api.deepseek.com/") { + return []string{"deepseek-chat", "deepseek-reasoner"} + } else if strings.Contains(api, "openrouter.ai") { + return ORFreeModels + } + return LocalModels + } + modelList := getModelListForAPI(cfg.CurrentAPI) + if len(args) == 0 { + fmt.Println("Models:") + for i, model := range modelList { + marker := " " + if model == chatBody.Model { + marker = "* " + } + fmt.Printf("%s%d: %s\n", marker, i, model) + } + fmt.Printf("\nCurrent model: %s\n", chatBody.Model) + return true + } + // Try index first, then model name + if idx, err := strconv.Atoi(args[0]); err == nil && idx >= 0 && idx < len(modelList) { + chatBody.Model = modelList[idx] + fmt.Printf("Switched to model: %s\n", chatBody.Model) + return true + } + if slices.Index(modelList, args[0]) < 0 { + fmt.Printf("Model '%s' not found. Use index or choose from:\n", args[0]) + for i, model := range modelList { + fmt.Printf(" %d: %s\n", i, model) + } + return true + } + chatBody.Model = args[0] + fmt.Printf("Switched to model: %s\n", args[0]) + case "/api", "/a": + if len(args) == 0 { + fmt.Println("API Links:") + for i, link := range cfg.ApiLinks { + marker := " " + if link == cfg.CurrentAPI { + marker = "* " + } + fmt.Printf("%s%d: %s\n", marker, i, link) + } + fmt.Printf("\nCurrent API: %s\n", cfg.CurrentAPI) + return true + } + idx := 0 + fmt.Sscanf(args[0], "%d", &idx) + if idx < 0 || idx >= len(cfg.ApiLinks) { + fmt.Printf("Invalid index. Valid range: 0-%d\n", len(cfg.ApiLinks)-1) + return true + } + cfg.CurrentAPI = cfg.ApiLinks[idx] + fmt.Printf("Switched to API: %s\n", cfg.CurrentAPI) + case "/quit", "/q", "/exit": + fmt.Println("Goodbye!") + return false + default: + fmt.Printf("Unknown command: %s\n", msg) + fmt.Println("Type /help for available commands.") + } + return true +} diff --git a/tools/chain.go b/tools/chain.go index 8a79eda..381cc1a 100644 --- a/tools/chain.go +++ b/tools/chain.go @@ -150,7 +150,7 @@ func execSingle(command, stdin string) (string, error) { name := parts[0] args := parts[1:] // Check if it's a built-in Go command - if result := execBuiltin(name, args, stdin); result != "" { + if result, isBuiltin := execBuiltin(name, args, stdin); isBuiltin { return result, nil } // Otherwise execute as system command @@ -201,21 +201,23 @@ func tokenize(input string) []string { } // execBuiltin executes a built-in command if it exists. -func execBuiltin(name string, args []string, stdin string) string { +// Returns (result, true) if it was a built-in (even if result is empty). +// Returns ("", false) if it's not a built-in command. +func execBuiltin(name string, args []string, stdin string) (string, bool) { switch name { case "echo": if stdin != "" { - return stdin + return stdin, true } - return strings.Join(args, " ") + return strings.Join(args, " "), true case "time": - return "2006-01-02 15:04:05 MST" + return "2006-01-02 15:04:05 MST", true case "cat": if len(args) == 0 { if stdin != "" { - return stdin + return stdin, true } - return "" + return "", true } path := args[0] abs := path @@ -224,14 +226,14 @@ func execBuiltin(name string, args []string, stdin string) string { } data, err := os.ReadFile(abs) if err != nil { - return fmt.Sprintf("[error] cat: %v", err) + return fmt.Sprintf("[error] cat: %v", err), true } - return string(data) + return string(data), true case "pwd": - return cfg.FilePickerDir + return cfg.FilePickerDir, true case "cd": if len(args) == 0 { - return "[error] usage: cd <dir>" + return "[error] usage: cd <dir>", true } dir := args[0] // Resolve relative to cfg.FilePickerDir @@ -242,16 +244,16 @@ func execBuiltin(name string, args []string, stdin string) string { abs = filepath.Clean(abs) info, err := os.Stat(abs) if err != nil { - return fmt.Sprintf("[error] cd: %v", err) + return fmt.Sprintf("[error] cd: %v", err), true } if !info.IsDir() { - return "[error] cd: not a directory: " + dir + return "[error] cd: not a directory: " + dir, true } cfg.FilePickerDir = abs - return "Changed directory to: " + cfg.FilePickerDir + return "Changed directory to: " + cfg.FilePickerDir, true case "mkdir": if len(args) == 0 { - return "[error] usage: mkdir [-p] <dir>" + return "[error] usage: mkdir [-p] <dir>", true } createParents := false var dirPath string @@ -263,7 +265,7 @@ func execBuiltin(name string, args []string, stdin string) string { } } if dirPath == "" { - return "[error] usage: mkdir [-p] <dir>" + return "[error] usage: mkdir [-p] <dir>", true } abs := dirPath if !filepath.IsAbs(dirPath) { @@ -277,12 +279,12 @@ func execBuiltin(name string, args []string, stdin string) string { mkdirFunc = os.Mkdir } if err := mkdirFunc(abs, 0o755); err != nil { - return fmt.Sprintf("[error] mkdir: %v", err) + return fmt.Sprintf("[error] mkdir: %v", err), true } if createParents { - return "Created " + dirPath + " (with parents)" + return "Created " + dirPath + " (with parents)", true } - return "Created " + dirPath + return "Created " + dirPath, true case "ls": dir := "." for _, a := range args { @@ -297,7 +299,7 @@ func execBuiltin(name string, args []string, stdin string) string { } entries, err := os.ReadDir(abs) if err != nil { - return fmt.Sprintf("[error] ls: %v", err) + return fmt.Sprintf("[error] ls: %v", err), true } var out strings.Builder for _, e := range entries { @@ -317,21 +319,98 @@ func execBuiltin(name string, args []string, stdin string) string { } } if out.Len() == 0 { - return "(empty directory)" + return "(empty directory)", true } - return strings.TrimRight(out.String(), "\n") + return strings.TrimRight(out.String(), "\n"), true case "go": // Allow all go subcommands if len(args) == 0 { - return "[error] usage: go <subcommand> [options]" + return "[error] usage: go <subcommand> [options]", true } cmd := exec.Command("go", args...) cmd.Dir = cfg.FilePickerDir output, err := cmd.CombinedOutput() if err != nil { - return fmt.Sprintf("[error] go %s: %v\n%s", args[0], err, string(output)) + return fmt.Sprintf("[error] go %s: %v\n%s", args[0], err, string(output)), true } - return string(output) + return string(output), true + case "cp": + if len(args) < 2 { + return "[error] usage: cp <source> <dest>", true + } + src := args[0] + dst := args[1] + if !filepath.IsAbs(src) { + src = filepath.Join(cfg.FilePickerDir, src) + } + if !filepath.IsAbs(dst) { + dst = filepath.Join(cfg.FilePickerDir, dst) + } + data, err := os.ReadFile(src) + if err != nil { + return fmt.Sprintf("[error] cp: %v", err), true + } + err = os.WriteFile(dst, data, 0644) + if err != nil { + return fmt.Sprintf("[error] cp: %v", err), true + } + return "Copied " + src + " to " + dst, true + case "mv": + if len(args) < 2 { + return "[error] usage: mv <source> <dest>", true + } + src := args[0] + dst := args[1] + if !filepath.IsAbs(src) { + src = filepath.Join(cfg.FilePickerDir, src) + } + if !filepath.IsAbs(dst) { + dst = filepath.Join(cfg.FilePickerDir, dst) + } + err := os.Rename(src, dst) + if err != nil { + return fmt.Sprintf("[error] mv: %v", err), true + } + return "Moved " + src + " to " + dst, true + case "rm": + if len(args) == 0 { + return "[error] usage: rm [-r] <file>", true + } + recursive := false + var target string + for _, a := range args { + if a == "-r" || a == "-rf" || a == "-fr" || a == "-recursive" { + recursive = true + } else if target == "" { + target = a + } + } + if target == "" { + return "[error] usage: rm [-r] <file>", true + } + abs := target + if !filepath.IsAbs(target) { + abs = filepath.Join(cfg.FilePickerDir, target) + } + info, err := os.Stat(abs) + if err != nil { + return fmt.Sprintf("[error] rm: %v", err), true + } + if info.IsDir() { + if recursive { + err = os.RemoveAll(abs) + if err != nil { + return fmt.Sprintf("[error] rm: %v", err), true + } + return "Removed " + abs, true + } + return "[error] rm: is a directory (use -r)", true + } + err = os.Remove(abs) + if err != nil { + return fmt.Sprintf("[error] rm: %v", err), true + } + return "Removed " + abs, true } - return "" + return "", false } diff --git a/tools/tools.go b/tools/tools.go index 697683a..967e5de 100644 --- a/tools/tools.go +++ b/tools/tools.go @@ -129,10 +129,10 @@ func (t *Tools) initAgentsB() { agent.RegisterB("summarize_chat", agent.NewWebAgentB(t.webAgentClient, summarySysPrompt)) } -func InitTools(cfg *config.Config, logger *slog.Logger, store storage.FullRepo) *Tools { - _ = logger - _ = cfg - if cfg.PlaywrightEnabled { +func InitTools(initCfg *config.Config, logger *slog.Logger, store storage.FullRepo) *Tools { + logger = logger + cfg = initCfg + if initCfg.PlaywrightEnabled { if err := CheckPlaywright(); err != nil { // slow, need a faster check if playwright install if err := InstallPW(); err != nil { @@ -686,7 +686,7 @@ Use: run "command" to execute.` -c count matches Example: run "grep error" (from stdin) - run "grep -i warning log.txt"` + run "grep -i warn log.txt"` case "cd": return `cd <directory> Change working directory. @@ -230,6 +230,7 @@ func initTUI() { tview.Styles = colorschemes["default"] app = tview.NewApplication() pages = tview.NewPages() + outputHandler = &TUIOutputHandler{tv: textView} shellInput = tview.NewInputField(). SetLabel(fmt.Sprintf("[%s]$ ", cfg.FilePickerDir)). // dynamic prompt SetFieldWidth(0). |
