summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGrail Finder <wohilas@gmail.com>2026-03-17 09:23:35 +0300
committerGrail Finder <wohilas@gmail.com>2026-03-17 09:23:35 +0300
commit451e6f0381afe37c59f12703f8750407c27ba94a (patch)
treed4a76d7011f2da98bac2ee718afa587952c8ba4f
parent47b3d37a9714e3e68e56f009ea1faee5223edeb4 (diff)
parent326a1a4d094c6349e0403d479384347c52964537 (diff)
Merge branch 'master' into feat/agent-flow
-rw-r--r--.gitignore2
-rw-r--r--bot.go166
-rwxr-xr-xcli-tests/sort-img/check.sh74
-rwxr-xr-xcli-tests/sort-img/run.sh25
-rwxr-xr-xcli-tests/sort-img/setup.sh9
-rw-r--r--cli-tests/sort-img/task.txt2
-rwxr-xr-xcli-tests/sort-img/teardown.sh4
-rwxr-xr-xcli-tests/sort-text/check.sh91
-rwxr-xr-xcli-tests/sort-text/run.sh25
-rwxr-xr-xcli-tests/sort-text/setup.sh10
-rw-r--r--cli-tests/sort-text/task.txt2
-rwxr-xr-xcli-tests/sort-text/teardown.sh4
-rw-r--r--config.example.toml2
-rw-r--r--config/config.go2
-rw-r--r--main.go255
-rw-r--r--tools/chain.go131
-rw-r--r--tools/tools.go10
-rw-r--r--tui.go1
18 files changed, 758 insertions, 57 deletions
diff --git a/.gitignore b/.gitignore
index b3baaec..a2f46b8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,4 +1,3 @@
-*.txt
*.json
testlog
history/
@@ -18,3 +17,4 @@ chat_exports/*.json
ragimport
.env
onnx/
+*.log
diff --git a/bot.go b/bot.go
index ec415ad..4f5e6cf 100644
--- a/bot.go
+++ b/bot.go
@@ -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) {
diff --git a/main.go b/main.go
index ddabff8..2a71920 100644
--- a/main.go
+++ b/main.go
@@ -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.
diff --git a/tui.go b/tui.go
index 98130fb..9f85a15 100644
--- a/tui.go
+++ b/tui.go
@@ -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).