29 Commits

Author SHA1 Message Date
Grail Finder
4428c06356 Enha (cli-tests): use relative path 2026-04-12 19:04:43 +03:00
Grail Finder
78918b2949 Chore: unix tools tests 2026-04-12 10:39:03 +03:00
Grail Finder
39e099cbe9 Enha: flag to pass model name 2026-04-12 09:02:32 +03:00
Grail Finder
aea19a6c0d Fix: buildable 2026-04-12 08:37:23 +03:00
Grail Finder
6befe7f4bf Chore: ignore batteries/whisper.cpp 2026-04-12 08:12:02 +03:00
Grail Finder
3e51ed2ceb Feat: handle latex 2026-04-12 07:45:03 +03:00
Grail Finder
ec3ccaae90 Feat (cli): /hs command 2026-04-11 18:19:56 +03:00
Grail Finder
1504214941 Fix (cli): update model list outside of tui 2026-04-11 16:19:52 +03:00
Grail Finder
50035e667b Fix (cli): tui panics 2026-04-11 16:07:15 +03:00
Grail Finder
76cc48eb54 Dep: update search agent (reddit support) 2026-04-11 15:23:16 +03:00
Grail Finder
43d78ff47b Enha (tools): move browser to top command 2026-04-11 14:04:18 +03:00
Grail Finder
556eab9d89 Enha: handle $rightarrow$ 2026-04-11 11:06:38 +03:00
Grail Finder
cdc237c81c Enha: dump failed request for debug 2026-04-09 14:28:15 +03:00
Grail Finder
85c0a0ec62 Chore: coding card update 2026-04-09 12:00:54 +03:00
Grail Finder
0bc6a09786 Enha: move flag parse into init 2026-04-09 10:24:27 +03:00
Grail Finder
ef67cbb456 Feat (rag): db viewer 2026-04-09 09:49:14 +03:00
Grail Finder
267feb0722 Fix (tools): '>' & '>>' 2026-04-08 17:43:28 +03:00
Grail Finder
5413c97b23 Fix (tools): passing tests 2026-04-08 14:44:07 +03:00
Grail Finder
aff7d73d16 Fix (tools): tests for tools and some fixes 2026-04-08 13:15:51 +03:00
Grail Finder
9488c5773e Enha: tool args preview 2026-04-08 12:26:59 +03:00
Grail Finder
77506950e4 Fix: ls handle -la flags 2026-04-08 12:15:44 +03:00
Grail Finder
9ff4a465d9 Chore: cleanup 2026-04-08 11:35:04 +03:00
Grail Finder
11fe89c243 Fix (tools): use fs tools 2026-04-08 10:37:00 +03:00
Grail Finder
4cf8833423 Enha: use tool atrribute to init agent client 2026-04-03 13:33:54 +03:00
Grail Finder
7a6d2b8777 Merge branch 'feat/agent-flow' 2026-03-19 07:18:08 +03:00
Grail Finder
69a69547ff Fix: streaming to tui 2026-03-17 10:52:42 +03:00
Grail Finder
3e4213b5c3 Fix: streaming to tui 2026-03-17 10:51:53 +03:00
Grail Finder
451e6f0381 Merge branch 'master' into feat/agent-flow 2026-03-17 09:23:35 +03:00
Grail Finder
47b3d37a97 Enha: remove browser agent from completion 2026-03-15 16:04:03 +03:00
23 changed files with 2344 additions and 882 deletions

3
.gitignore vendored
View File

@@ -18,3 +18,6 @@ ragimport
.env .env
onnx/ onnx/
*.log *.log
log.txt
dumps/
batteries/whisper.cpp

Submodule batteries/whisper.cpp deleted from a88b93f85f

102
bot.go
View File

@@ -48,10 +48,9 @@ var (
chunkParser ChunkParser chunkParser ChunkParser
lastToolCall *models.FuncCall lastToolCall *models.FuncCall
lastRespStats *models.ResponseStats lastRespStats *models.ResponseStats
outputHandler OutputHandler
outputHandler OutputHandler cliPrevOutput string
cliPrevOutput string cliRespDone chan bool
cliRespDone chan bool
) )
type OutputHandler interface { type OutputHandler interface {
@@ -65,30 +64,15 @@ type TUIOutputHandler struct {
} }
func (h *TUIOutputHandler) Write(p string) { func (h *TUIOutputHandler) Write(p string) {
if h.tv != nil { fmt.Fprint(h.tv, p)
fmt.Fprint(h.tv, p)
}
if cfg != nil && cfg.CLIMode {
fmt.Print(p)
cliPrevOutput = p
}
} }
func (h *TUIOutputHandler) Writef(format string, args ...interface{}) { func (h *TUIOutputHandler) Writef(format string, args ...interface{}) {
s := fmt.Sprintf(format, args...) fmt.Fprintf(h.tv, 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() { func (h *TUIOutputHandler) ScrollToEnd() {
if h.tv != nil { h.tv.ScrollToEnd()
h.tv.ScrollToEnd()
}
} }
type CLIOutputHandler struct{} type CLIOutputHandler struct{}
@@ -140,8 +124,6 @@ var (
orModelsData *models.ORModels orModelsData *models.ORModels
) )
var thinkBlockRE = regexp.MustCompile(`(?s)<think>.*?</think>`)
// parseKnownToTag extracts known_to list from content using configured tag. // parseKnownToTag extracts known_to list from content using configured tag.
// Returns cleaned content and list of character names. // Returns cleaned content and list of character names.
func parseKnownToTag(content string) []string { func parseKnownToTag(content string) []string {
@@ -649,12 +631,57 @@ func finalizeRespStats(tokenCount int, startTime time.Time) {
} }
} }
func dumpRequestToFile(api string, body []byte, token string, statusCode int, respError string) {
dumpDir := "dumps"
if err := os.MkdirAll(dumpDir, 0755); err != nil {
logger.Warn("failed to create dumps directory", "error", err)
return
}
timestamp := time.Now().Format("20060102_150405")
bodyFilename := fmt.Sprintf("%s/request_%s_%d_body.json", dumpDir, timestamp, statusCode)
curlFilename := fmt.Sprintf("%s/request_%s_%d.curl", dumpDir, timestamp, statusCode)
if err := os.WriteFile(bodyFilename, body, 0644); err != nil {
logger.Warn("failed to write request body dump", "error", err, "filename", bodyFilename)
return
}
var authPart string
if token != "" {
authPart = fmt.Sprintf(`-H "Authorization: Bearer %s"`, token)
}
curlCmd := fmt.Sprintf(`curl -X POST "%s" \
-H "Content-Type: application/json" \
%s \
--data-binary @%s`,
api, authPart, bodyFilename)
if err := os.WriteFile(curlFilename, []byte(curlCmd), 0644); err != nil {
logger.Warn("failed to write request dump", "error", err, "filename", curlFilename)
return
}
logger.Info("request dump saved", "curl_file", curlFilename, "body_file", bodyFilename, "status", statusCode)
}
// sendMsgToLLM expects streaming resp // sendMsgToLLM expects streaming resp
func sendMsgToLLM(body io.Reader) { func sendMsgToLLM(body io.Reader) {
choseChunkParser() choseChunkParser()
// openrouter does not respect stop strings, so we have to cut the message ourselves // openrouter does not respect stop strings, so we have to cut the message ourselves
stopStrings := chatBody.MakeStopSliceExcluding("", listChatRoles()) stopStrings := chatBody.MakeStopSliceExcluding("", listChatRoles())
req, err := http.NewRequest("POST", cfg.CurrentAPI, body)
// Read body content for potential dump on error
bodyBytes, err := io.ReadAll(body)
if err != nil {
logger.Error("failed to read request body", "error", err)
showToast("error", "apicall failed:"+err.Error())
streamDone <- true
return
}
req, err := http.NewRequest("POST", cfg.CurrentAPI, bytes.NewReader(bodyBytes))
if err != nil { if err != nil {
logger.Error("newreq error", "error", err) logger.Error("newreq error", "error", err)
showToast("error", "apicall failed:"+err.Error()) showToast("error", "apicall failed:"+err.Error())
@@ -676,7 +703,7 @@ func sendMsgToLLM(body io.Reader) {
// Check if the initial response is an error before starting to stream // Check if the initial response is an error before starting to stream
if resp.StatusCode >= 400 { if resp.StatusCode >= 400 {
// Read the response body to get detailed error information // Read the response body to get detailed error information
bodyBytes, err := io.ReadAll(resp.Body) respBodyBytes, err := io.ReadAll(resp.Body)
if err != nil { if err != nil {
logger.Error("failed to read error response body", "error", err, "status_code", resp.StatusCode) logger.Error("failed to read error response body", "error", err, "status_code", resp.StatusCode)
detailedError := fmt.Sprintf("HTTP Status: %d, Failed to read response body: %v", resp.StatusCode, err) detailedError := fmt.Sprintf("HTTP Status: %d, Failed to read response body: %v", resp.StatusCode, err)
@@ -686,8 +713,9 @@ func sendMsgToLLM(body io.Reader) {
return return
} }
// Parse the error response for detailed information // Parse the error response for detailed information
detailedError := extractDetailedErrorFromBytes(bodyBytes, resp.StatusCode) detailedError := extractDetailedErrorFromBytes(respBodyBytes, resp.StatusCode)
logger.Error("API returned error status", "status_code", resp.StatusCode, "detailed_error", detailedError) logger.Error("API returned error status", "status_code", resp.StatusCode, "detailed_error", detailedError)
dumpRequestToFile(cfg.CurrentAPI, bodyBytes, chunkParser.GetToken(), resp.StatusCode, detailedError)
showToast("API Error", detailedError) showToast("API Error", detailedError)
resp.Body.Close() resp.Body.Close()
streamDone <- true streamDone <- true
@@ -1070,7 +1098,7 @@ out:
logger.Warn("failed to update storage", "error", err, "name", activeChatName) logger.Warn("failed to update storage", "error", err, "name", activeChatName)
} }
// Strip think blocks before parsing for tool calls // Strip think blocks before parsing for tool calls
respTextNoThink := thinkBlockRE.ReplaceAllString(respText.String(), "") respTextNoThink := models.ThinkRE.ReplaceAllString(respText.String(), "")
if interruptResp.Load() { if interruptResp.Load() {
return nil return nil
} }
@@ -1414,10 +1442,14 @@ func chatToTextSlice(messages []models.RoleMsg, showSys bool) []string {
// This is a tool call indicator - show collapsed // This is a tool call indicator - show collapsed
if toolCollapsed { if toolCollapsed {
toolName := messages[i].ToolCall.Name toolName := messages[i].ToolCall.Name
argsPreview := messages[i].ToolCall.Args
if len(messages[i].ToolCall.Args) > 30 {
argsPreview = messages[i].ToolCall.Args[:30]
}
resp[i] = strings.ReplaceAll( resp[i] = strings.ReplaceAll(
fmt.Sprintf( fmt.Sprintf(
"%s\n%s\n[yellow::i][tool call: %s (press Ctrl+T to expand)][-:-:-]\n", "%s\n%s\n[yellow::i][tool call: %s %s (press Ctrl+T to expand)][-:-:-]\n",
icon, messages[i].GetText(), toolName), icon, messages[i].GetText(), toolName, argsPreview),
"\n\n", "\n") "\n\n", "\n")
} else { } else {
// Show full tool call info // Show full tool call info
@@ -1560,7 +1592,9 @@ func updateModelLists() {
cachedModelColor.Store("green") cachedModelColor.Store("green")
updateStatusLine() updateStatusLine()
UpdateToolCapabilities() UpdateToolCapabilities()
app.Draw() if !cfg.CLIMode {
app.Draw() // raw?
}
return return
} }
} }
@@ -1736,10 +1770,4 @@ func init() {
// atomic default values // atomic default values
cachedModelColor.Store("orange") cachedModelColor.Store("orange")
go chatWatcher(ctx) go chatWatcher(ctx)
if !cfg.CLIMode {
initTUI()
}
tools.InitTools(cfg, logger, store)
// tooler = tools.InitTools(cfg, logger, store)
// tooler.RegisterWindowTools(modelHasVision)
} }

View File

@@ -17,9 +17,11 @@ echo "=== Running setup ==="
echo "" echo ""
echo "=== Running task ===" echo "=== Running task ==="
TASK=$(cat "$SCRIPT_DIR/task.txt") TASK=$(cat "$SCRIPT_DIR/task.txt")
cd /home/grail/projects/plays/goplays/gf-lt LMODEL=${LMODEL:-Qwen3.5-9B-Q6_K}
go run . -cli -msg "$TASK" cd ../../
go run . -cli -msg "$TASK" -model "$LMODEL"
echo "" echo ""
echo "=== Done ===" echo "=== Done ==="
cp "$LOG_FILE" "$SCRIPT_DIR/latest_run.log"
echo "Log file: $LOG_FILE" echo "Log file: $LOG_FILE"

View File

@@ -2,8 +2,10 @@
set -e set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
mkdir -p /tmp/sort-img mkdir -p /tmp/sort-img
cp ../../../assets/ex01.png /tmp/sort-img/file1.png cp "$SCRIPT_DIR/../../assets/ex01.png" /tmp/sort-img/file1.png
cp ../../../assets/helppage.png /tmp/sort-img/file2.png cp "$SCRIPT_DIR/../../assets/helppage.png" /tmp/sort-img/file2.png
cp ../../../assets/yt_thumb.jpg /tmp/sort-img/file3.jpg cp "$SCRIPT_DIR/../../assets/yt_thumb.jpg" /tmp/sort-img/file3.jpg

View File

@@ -17,9 +17,12 @@ echo "=== Running setup ==="
echo "" echo ""
echo "=== Running task ===" echo "=== Running task ==="
TASK=$(cat "$SCRIPT_DIR/task.txt") TASK=$(cat "$SCRIPT_DIR/task.txt")
cd /home/grail/projects/plays/goplays/gf-lt # LMODEL=${LMODEL:-gemma-4-31B-it-Q4_K_M}
go run . -cli -msg "$TASK" LMODEL=${LMODEL:-Qwen3.5-9B-Q6_K}
cd ../../
go run . -cli -msg "$TASK" -model "$LMODEL"
echo "" echo ""
echo "=== Done ===" echo "=== Done ==="
cp "$LOG_FILE" "$SCRIPT_DIR/latest_run.log"
echo "Log file: $LOG_FILE" echo "Log file: $LOG_FILE"

View File

@@ -30,6 +30,7 @@ type Config struct {
DBPATH string `toml:"DBPATH"` DBPATH string `toml:"DBPATH"`
FilePickerDir string `toml:"FilePickerDir"` FilePickerDir string `toml:"FilePickerDir"`
FilePickerExts string `toml:"FilePickerExts"` FilePickerExts string `toml:"FilePickerExts"`
FSAllowOutOfRoot bool `toml:"FSAllowOutOfRoot"`
ImagePreview bool `toml:"ImagePreview"` ImagePreview bool `toml:"ImagePreview"`
EnableMouse bool `toml:"EnableMouse"` EnableMouse bool `toml:"EnableMouse"`
// embeddings // embeddings
@@ -76,7 +77,8 @@ type Config struct {
PlaywrightEnabled bool `toml:"PlaywrightEnabled"` PlaywrightEnabled bool `toml:"PlaywrightEnabled"`
PlaywrightDebug bool `toml:"PlaywrightDebug"` // !headless PlaywrightDebug bool `toml:"PlaywrightDebug"` // !headless
// CLI mode // CLI mode
CLIMode bool CLIMode bool
UseNotifySend bool
} }
func LoadConfig(fn string) (*Config, error) { func LoadConfig(fn string) (*Config, error) {
@@ -88,6 +90,15 @@ func LoadConfig(fn string) (*Config, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
// Default FilePickerDir to current working directory if not set
if config.FilePickerDir == "" {
cwd, err := os.Getwd()
if err != nil {
config.FilePickerDir = "."
} else {
config.FilePickerDir = cwd
}
}
config.CurrentAPI = config.ChatAPI config.CurrentAPI = config.ChatAPI
config.APIMap = map[string]string{ config.APIMap = map[string]string{
config.ChatAPI: config.CompletionAPI, config.ChatAPI: config.CompletionAPI,

2
go.mod
View File

@@ -5,7 +5,7 @@ go 1.25.1
require ( require (
github.com/BurntSushi/toml v1.5.0 github.com/BurntSushi/toml v1.5.0
github.com/GrailFinder/google-translate-tts v0.1.4 github.com/GrailFinder/google-translate-tts v0.1.4
github.com/GrailFinder/searchagent v0.2.0 github.com/GrailFinder/searchagent v0.2.1
github.com/PuerkitoBio/goquery v1.11.0 github.com/PuerkitoBio/goquery v1.11.0
github.com/gdamore/tcell/v2 v2.13.2 github.com/gdamore/tcell/v2 v2.13.2
github.com/glebarez/go-sqlite v1.22.0 github.com/glebarez/go-sqlite v1.22.0

4
go.sum
View File

@@ -4,8 +4,8 @@ github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/GrailFinder/google-translate-tts v0.1.4 h1:NJoPZUGfBrmouQMN19MUcNPNUx4tmf4a8OZRME4E4Mg= github.com/GrailFinder/google-translate-tts v0.1.4 h1:NJoPZUGfBrmouQMN19MUcNPNUx4tmf4a8OZRME4E4Mg=
github.com/GrailFinder/google-translate-tts v0.1.4/go.mod h1:YIOLKR7sObazdUCrSex3u9OVBovU55eYgWa25vsQJ18= github.com/GrailFinder/google-translate-tts v0.1.4/go.mod h1:YIOLKR7sObazdUCrSex3u9OVBovU55eYgWa25vsQJ18=
github.com/GrailFinder/searchagent v0.2.0 h1:U2GVjLh/9xZt0xX9OcYk9Q2fMkyzyTiADPUmUisRdtQ= github.com/GrailFinder/searchagent v0.2.1 h1:c2A8UXEkAMhJgheUzhz4eRH4qvDfRJdZ0PB+Pf6TTAo=
github.com/GrailFinder/searchagent v0.2.0/go.mod h1:d66tn5+22LI8IGJREUsRBT60P0sFdgQgvQRqyvgItrs= github.com/GrailFinder/searchagent v0.2.1/go.mod h1:d66tn5+22LI8IGJREUsRBT60P0sFdgQgvQRqyvgItrs=
github.com/PuerkitoBio/goquery v1.11.0 h1:jZ7pwMQXIITcUXNH83LLk+txlaEy6NVOfTuP43xxfqw= github.com/PuerkitoBio/goquery v1.11.0 h1:jZ7pwMQXIITcUXNH83LLk+txlaEy6NVOfTuP43xxfqw=
github.com/PuerkitoBio/goquery v1.11.0/go.mod h1:wQHgxUOU3JGuj3oD/QFfxUdlzW6xPHfqyHre6VMY4DQ= github.com/PuerkitoBio/goquery v1.11.0/go.mod h1:wQHgxUOU3JGuj3oD/QFfxUdlzW6xPHfqyHre6VMY4DQ=
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM= github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=

View File

@@ -98,6 +98,9 @@ func stripThinkingFromMsg(msg *models.RoleMsg) *models.RoleMsg {
// It filters messages for the character the user is currently "writing as" // It filters messages for the character the user is currently "writing as"
// and updates the textView with the filtered conversation // and updates the textView with the filtered conversation
func refreshChatDisplay() { func refreshChatDisplay() {
if cfg.CLIMode {
return
}
// Determine which character's view to show // Determine which character's view to show
viewingAs := cfg.UserRole viewingAs := cfg.UserRole
if cfg.WriteNextMsgAs != "" { if cfg.WriteNextMsgAs != "" {
@@ -178,10 +181,15 @@ func colorText() {
for i, tb := range thinkBlocks { for i, tb := range thinkBlocks {
text = strings.Replace(text, fmt.Sprintf(placeholderThink, i), tb, 1) text = strings.Replace(text, fmt.Sprintf(placeholderThink, i), tb, 1)
} }
// text = strings.ReplaceAll(text, `$\rightarrow$`, "->")
text = RenderLatex(text)
textView.SetText(text) textView.SetText(text)
} }
func updateStatusLine() { func updateStatusLine() {
if cfg.CLIMode {
return // no status line in cli mode
}
status := makeStatusLine() status := makeStatusLine()
statusLineWidget.SetText(status) statusLineWidget.SetText(status)
} }
@@ -595,7 +603,6 @@ func executeCommandAndDisplay(cmdText string) {
return return
} }
} }
// Use /bin/sh to support pipes, redirects, etc. // Use /bin/sh to support pipes, redirects, etc.
cmd := exec.Command("/bin/sh", "-c", cmdText) cmd := exec.Command("/bin/sh", "-c", cmdText)
cmd.Dir = workingDir cmd.Dir = workingDir
@@ -1023,3 +1030,19 @@ func GetCardByRole(role string) *models.CharCard {
} }
return sysMap[cardID] return sysMap[cardID]
} }
func notifySend(topic, message string) error {
// Sanitize message to remove control characters that notify-send doesn't handle
sanitized := strings.Map(func(r rune) rune {
if r < 32 && r != '\t' {
return -1
}
return r
}, message)
// Truncate if too long
if len(sanitized) > 200 {
sanitized = sanitized[:197] + "..."
}
cmd := exec.Command("notify-send", topic, sanitized)
return cmd.Run()
}

190
latex.go Normal file
View File

@@ -0,0 +1,190 @@
package main
import (
"regexp"
"strings"
)
var (
mathInline = regexp.MustCompile(`\$([^\$]+)\$`) // $...$
mathDisplay = regexp.MustCompile(`\$\$([^\$]+)\$\$`) // $$...$$
)
// RenderLatex converts all LaTeX math blocks in a string to terminalfriendly text.
func RenderLatex(text string) string {
// Handle display math ($$...$$) add newlines for separation
text = mathDisplay.ReplaceAllStringFunc(text, func(match string) string {
inner := mathDisplay.FindStringSubmatch(match)[1]
return "\n" + convertLatex(inner) + "\n"
})
// Handle inline math ($...$)
text = mathInline.ReplaceAllStringFunc(text, func(match string) string {
inner := mathInline.FindStringSubmatch(match)[1]
return convertLatex(inner)
})
return text
}
func convertLatex(s string) string {
// ----- 1. Greek letters -----
greek := map[string]string{
`\alpha`: "α", `\beta`: "β", `\gamma`: "γ", `\delta`: "δ",
`\epsilon`: "ε", `\zeta`: "ζ", `\eta`: "η", `\theta`: "θ",
`\iota`: "ι", `\kappa`: "κ", `\lambda`: "λ", `\mu`: "μ",
`\nu`: "ν", `\xi`: "ξ", `\pi`: "π", `\rho`: "ρ",
`\sigma`: "σ", `\tau`: "τ", `\upsilon`: "υ", `\phi`: "φ",
`\chi`: "χ", `\psi`: "ψ", `\omega`: "ω",
`\Gamma`: "Γ", `\Delta`: "Δ", `\Theta`: "Θ", `\Lambda`: "Λ",
`\Xi`: "Ξ", `\Pi`: "Π", `\Sigma`: "Σ", `\Upsilon`: "Υ",
`\Phi`: "Φ", `\Psi`: "Ψ", `\Omega`: "Ω",
}
for cmd, uni := range greek {
s = strings.ReplaceAll(s, cmd, uni)
}
// ----- 2. Arrows, relations, operators, symbols -----
symbols := map[string]string{
// Arrows
`\leftarrow`: "←", `\rightarrow`: "→", `\leftrightarrow`: "↔",
`\Leftarrow`: "⇐", `\Rightarrow`: "⇒", `\Leftrightarrow`: "⇔",
`\uparrow`: "↑", `\downarrow`: "↓", `\updownarrow`: "↕",
`\mapsto`: "↦", `\to`: "→", `\gets`: "←",
// Relations
`\le`: "≤", `\ge`: "≥", `\neq`: "≠", `\approx`: "≈",
`\equiv`: "≡", `\pm`: "±", `\mp`: "∓", `\times`: "×",
`\div`: "÷", `\cdot`: "·", `\circ`: "°", `\bullet`: "•",
// Other symbols
`\infty`: "∞", `\partial`: "∂", `\nabla`: "∇", `\exists`: "∃",
`\forall`: "∀", `\in`: "∈", `\notin`: "∉", `\subset`: "⊂",
`\subseteq`: "⊆", `\supset`: "⊃", `\supseteq`: "⊇", `\cup`: "",
`\cap`: "∩", `\emptyset`: "∅", `\ell`: "", `\Re`: "",
`\Im`: "", `\wp`: "℘", `\dag`: "†", `\ddag`: "‡",
`\prime`: "", `\degree`: "°", // some LLMs output \degree
}
for cmd, uni := range symbols {
s = strings.ReplaceAll(s, cmd, uni)
}
// ----- 3. Remove \text{...} -----
textRe := regexp.MustCompile(`\\text\{([^}]*)\}`)
s = textRe.ReplaceAllString(s, "$1")
// ----- 4. Fractions: \frac{a}{b} → a/b -----
fracRe := regexp.MustCompile(`\\frac\{([^{}]*(?:\{[^{}]*\}[^{}]*)*)\}\{([^{}]*(?:\{[^{}]*\}[^{}]*)*)\}`)
s = fracRe.ReplaceAllString(s, "$1/$2")
// ----- 5. Remove formatting commands (\mathrm, \mathbf, etc.) -----
for _, cmd := range []string{"mathrm", "mathbf", "mathit", "mathsf", "mathtt", "mathbb", "mathcal"} {
re := regexp.MustCompile(`\\` + cmd + `\{([^}]*)\}`)
s = re.ReplaceAllString(s, "$1")
}
// ----- 6. Subscripts and superscripts -----
s = convertSubscripts(s)
s = convertSuperscripts(s)
// ----- 7. Clean up leftover braces (but keep backslashes) -----
s = strings.ReplaceAll(s, "{", "")
s = strings.ReplaceAll(s, "}", "")
// ----- 8. (Optional) Remove any remaining backslash+word if you really want -----
// But as discussed, this can break things. I'll leave it commented.
// cmdRe := regexp.MustCompile(`\\([a-zA-Z]+)`)
// s = cmdRe.ReplaceAllString(s, "$1")
return s
}
// Subscript converter (handles both _{...} and _x)
func convertSubscripts(s string) string {
subMap := map[rune]string{
'0': "₀", '1': "₁", '2': "₂", '3': "₃", '4': "₄",
'5': "₅", '6': "₆", '7': "₇", '8': "₈", '9': "₉",
'+': "₊", '-': "₋", '=': "₌", '(': "₍", ')': "₎",
'a': "ₐ", 'e': "ₑ", 'i': "ᵢ", 'o': "ₒ", 'u': "ᵤ",
'v': "ᵥ", 'x': "ₓ",
}
// Braced: _{...}
reBraced := regexp.MustCompile(`_\{([^}]*)\}`)
s = reBraced.ReplaceAllStringFunc(s, func(match string) string {
inner := reBraced.FindStringSubmatch(match)[1]
return subscriptify(inner, subMap)
})
// Unbraced: _x (single character)
reUnbraced := regexp.MustCompile(`_([a-zA-Z0-9])`)
s = reUnbraced.ReplaceAllStringFunc(s, func(match string) string {
ch := rune(match[1])
if sub, ok := subMap[ch]; ok {
return sub
}
return match // keep original _x
})
return s
}
func subscriptify(inner string, subMap map[rune]string) string {
var out strings.Builder
for _, ch := range inner {
if sub, ok := subMap[ch]; ok {
out.WriteString(sub)
} else {
return "_{" + inner + "}" // fallback
}
}
return out.String()
}
// Superscript converter (handles both ^{...} and ^x)
func convertSuperscripts(s string) string {
supMap := map[rune]string{
'0': "⁰", '1': "¹", '2': "²", '3': "³", '4': "⁴",
'5': "⁵", '6': "⁶", '7': "⁷", '8': "⁸", '9': "⁹",
'+': "⁺", '-': "⁻", '=': "⁼", '(': "⁽", ')': "⁾",
'n': "ⁿ", 'i': "ⁱ",
}
// Special single-character superscripts that replace the caret entirely
specialSup := map[string]string{
"°": "°", // degree
"'": "", // prime
"\"": "″", // double prime
}
// Braced: ^{...}
reBraced := regexp.MustCompile(`\^\{(.*?)\}`)
s = reBraced.ReplaceAllStringFunc(s, func(match string) string {
inner := reBraced.FindStringSubmatch(match)[1]
return superscriptify(inner, supMap, specialSup)
})
// Unbraced: ^x (single character)
reUnbraced := regexp.MustCompile(`\^([^\{[:space:]]?)`)
s = reUnbraced.ReplaceAllStringFunc(s, func(match string) string {
if len(match) < 2 {
return match
}
ch := match[1:]
if special, ok := specialSup[ch]; ok {
return special
}
if len(ch) == 1 {
if sup, ok := supMap[rune(ch[0])]; ok {
return sup
}
}
return match // keep ^x
})
return s
}
func superscriptify(inner string, supMap map[rune]string, specialSup map[string]string) string {
if special, ok := specialSup[inner]; ok {
return special
}
var out strings.Builder
for _, ch := range inner {
if sup, ok := supMap[ch]; ok {
out.WriteString(sup)
} else {
return "^{" + inner + "}" // fallback
}
}
return out.String()
}

39
main.go
View File

@@ -6,6 +6,7 @@ import (
"fmt" "fmt"
"gf-lt/models" "gf-lt/models"
"gf-lt/pngmeta" "gf-lt/pngmeta"
"gf-lt/tools"
"os" "os"
"slices" "slices"
"strconv" "strconv"
@@ -37,12 +38,22 @@ var (
) )
func main() { func main() {
// parse flags
flag.BoolVar(&cfg.CLIMode, "cli", false, "Run in CLI mode without TUI") flag.BoolVar(&cfg.CLIMode, "cli", false, "Run in CLI mode without TUI")
flag.BoolVar(&cfg.ToolUse, "tools", true, "run with tools") flag.BoolVar(&cfg.ToolUse, "tools", true, "run with tools")
flag.StringVar(&cfg.CurrentModel, "model", "modelname", "name of the model to use")
flag.StringVar(&cliCardPath, "card", "", "Path to syscard JSON file") flag.StringVar(&cliCardPath, "card", "", "Path to syscard JSON file")
flag.BoolVar(&cliContinue, "continue", false, "Continue from last chat (by agent or card)") 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.StringVar(&cliMsg, "msg", "", "Send message and exit (one-shot mode)")
flag.Parse() flag.Parse()
if !cfg.CLIMode {
initTUI()
}
chatBody.Model = cfg.CurrentModel
go updateModelLists()
tools.InitTools(cfg, logger, store)
// tooler = tools.InitTools(cfg, logger, store)
// tooler.RegisterWindowTools(modelHasVision)
if cfg.CLIMode { if cfg.CLIMode {
runCLIMode() runCLIMode()
return return
@@ -141,7 +152,8 @@ func printCLIHelp() {
fmt.Println(" /new, /n - Start a new chat (clears conversation)") fmt.Println(" /new, /n - Start a new chat (clears conversation)")
fmt.Println(" /card <path>, /c <path> - Load a different syscard") fmt.Println(" /card <path>, /c <path> - Load a different syscard")
fmt.Println(" /undo, /u - Delete last message") fmt.Println(" /undo, /u - Delete last message")
fmt.Println(" /history, /ls - List chat history") fmt.Println(" /history, /ls - List chat sessions")
fmt.Println(" /hs [index] - Show chat history (messages)")
fmt.Println(" /load <name> - Load a specific chat by name") fmt.Println(" /load <name> - Load a specific chat by name")
fmt.Println(" /model <name>, /m <name> - Switch model") fmt.Println(" /model <name>, /m <name> - Switch model")
fmt.Println(" /api <index>, /a <index> - Switch API link (no index to list)") fmt.Println(" /api <index>, /a <index> - Switch API link (no index to list)")
@@ -220,6 +232,31 @@ func handleCLICommand(msg string) bool {
activeChatName = name activeChatName = name
cfg.AssistantRole = chat.Agent cfg.AssistantRole = chat.Agent
fmt.Printf("Loaded chat: %s\n", name) fmt.Printf("Loaded chat: %s\n", name)
case "/hs":
if len(chatBody.Messages) == 0 {
fmt.Println("No messages in current chat.")
return true
}
if len(args) == 0 {
fmt.Println("Chat history:")
for i := range chatBody.Messages {
fmt.Printf("%d: %s\n", i, MsgToText(i, &chatBody.Messages[i]))
}
return true
}
idx, err := strconv.Atoi(args[0])
if err != nil {
fmt.Printf("Invalid index: %s\n", args[0])
return true
}
if idx < 0 {
idx = len(chatBody.Messages) + idx
}
if idx < 0 || idx >= len(chatBody.Messages) {
fmt.Printf("Index out of range (0-%d)\n", len(chatBody.Messages)-1)
return true
}
fmt.Printf("%d: %s\n", idx, MsgToText(idx, &chatBody.Messages[idx]))
case "/model", "/m": case "/model", "/m":
getModelListForAPI := func(api string) []string { getModelListForAPI := func(api string) []string {
if strings.Contains(api, "api.deepseek.com/") { if strings.Contains(api, "api.deepseek.com/") {

View File

@@ -126,6 +126,9 @@ func makePropsTable(props map[string]float32) *tview.Table {
addCheckboxRow("Image Preview (file picker)", cfg.ImagePreview, func(checked bool) { addCheckboxRow("Image Preview (file picker)", cfg.ImagePreview, func(checked bool) {
cfg.ImagePreview = checked cfg.ImagePreview = checked
}) })
addCheckboxRow("Allow FS out of root", cfg.FSAllowOutOfRoot, func(checked bool) {
cfg.FSAllowOutOfRoot = checked
})
addCheckboxRow("Auto turn (for cards with many chars)", cfg.AutoTurn, func(checked bool) { addCheckboxRow("Auto turn (for cards with many chars)", cfg.AutoTurn, func(checked bool) {
cfg.AutoTurn = checked cfg.AutoTurn = checked
}) })

View File

@@ -54,6 +54,12 @@ func (d dummyStore) Recall(agent, topic string) (string, error) { return
func (d dummyStore) RecallTopics(agent string) ([]string, error) { return nil, nil } func (d dummyStore) RecallTopics(agent string) ([]string, error) { return nil, nil }
func (d dummyStore) Forget(agent, topic string) error { return nil } func (d dummyStore) Forget(agent, topic string) error { return nil }
// TableLister method
func (d dummyStore) ListTables() ([]string, error) { return nil, nil }
func (d dummyStore) GetTableColumns(table string) ([]storage.TableColumn, error) {
return nil, nil
}
// VectorRepo methods (not used but required by interface) // VectorRepo methods (not used but required by interface)
func (d dummyStore) WriteVector(row *models.VectorRow) error { return nil } func (d dummyStore) WriteVector(row *models.VectorRow) error { return nil }
func (d dummyStore) SearchClosest(q []float32, limit int) ([]models.VectorRow, error) { func (d dummyStore) SearchClosest(q []float32, limit int) ([]models.VectorRow, error) {

View File

@@ -1,6 +1,7 @@
package storage package storage
import ( import (
"database/sql"
"gf-lt/models" "gf-lt/models"
"log/slog" "log/slog"
@@ -12,6 +13,12 @@ type FullRepo interface {
ChatHistory ChatHistory
Memories Memories
VectorRepo VectorRepo
TableLister
}
type TableLister interface {
ListTables() ([]string, error)
GetTableColumns(table string) ([]TableColumn, error)
} }
type ChatHistory interface { type ChatHistory interface {
@@ -130,3 +137,24 @@ func NewProviderSQL(dbPath string, logger *slog.Logger) FullRepo {
func (p ProviderSQL) DB() *sqlx.DB { func (p ProviderSQL) DB() *sqlx.DB {
return p.db return p.db
} }
type TableColumn struct {
CID int `db:"cid"`
Name string `db:"name"`
Type string `db:"type"`
NotNull bool `db:"notnull"`
DFltVal sql.NullString `db:"dflt_value"`
PK int `db:"pk"`
}
func (p ProviderSQL) ListTables() ([]string, error) {
resp := []string{}
err := p.db.Select(&resp, "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name;")
return resp, err
}
func (p ProviderSQL) GetTableColumns(table string) ([]TableColumn, error) {
resp := []TableColumn{}
err := p.db.Select(&resp, "PRAGMA table_info("+table+");")
return resp, err
}

View File

@@ -1,5 +1,5 @@
{ {
"sys_prompt": "You are an expert software engineering assistant. Your goal is to help users with coding tasks, debugging, refactoring, and software development.\n\n## Core Principles\n1. **Security First**: Never expose secrets, keys, or credentials. Never commit sensitive data.\n2. **No Git Actions**: You can READ git info (status, log, diff) for context, but NEVER perform git actions (commit, add, push, checkout, reset, rm, etc.). Let the user handle all git operations.\n3. **Explore Before Execute**: Always understand the codebase structure before making changes.\n4. **Follow Conventions**: Match existing code style, patterns, and frameworks used in the project.\n5. **Be Concise**: Minimize output tokens while maintaining quality. Avoid unnecessary explanations.\n6. **Ask First**: When uncertain about intent, ask the user. Don't assume.\n\n## Workflow for Complex Tasks\nFor multi-step tasks, ALWAYS use the todo system to track progress:\n\n1. **Create Todo List**: At the start of complex tasks, use `todo_create` to break down work into actionable items.\n2. **Update Progress**: Mark items as `in_progress` when working on them, and `completed` when done.\n3. **Check Status**: Use `todo_read` to review your progress.\n\nExample workflow:\n- User: \"Add user authentication to this app\"\n- You: Create todos: [\"Analyze existing auth structure\", \"Check frameworks in use\", \"Implement auth middleware\", \"Add login endpoints\", \"Test implementation\"]\n\n## Task Execution Flow\n\n### Phase 1: Exploration (Always First)\n- Use `file_list` to understand directory structure (path defaults to FilePickerDir if not specified)\n- Use `file_read` to examine relevant files (paths are relative to FilePickerDir unless starting with `/`)\n- Use `execute_command` with `grep`/`find` to search for patterns\n- Check README, Makefile, package.json, or similar for build/test commands\n- Identify: frameworks, conventions, testing approach, lint/typecheck commands\n- **Git reads allowed**: You may use `git status`, `git log`, `git diff` for context, but only to inform your work\n- **Path handling**: Relative paths resolve against FilePickerDir; absolute paths (starting with `/`) bypass it\n\n### Phase 2: Planning\n- For complex tasks: create todo items\n- Identify files that need modification\n- Plan your approach following existing patterns\n\n### Phase 3: Implementation\n- Make changes using appropriate file tools\n- Prefer `file_write` for new files, `file_read` then edit for existing files\n- Follow existing code style exactly\n- Use existing libraries and utilities\n\n### Phase 4: Verification\n- Run tests if available (check for test scripts in README/Makefile)\n- Run linting/type checking commands\n- Verify changes work as expected\n\n### Phase 5: Completion\n- Update todos to `completed`\n- Provide concise summary of changes\n- Reference specific file paths and line numbers when relevant\n- **DO NOT commit changes** - inform user what was done so they can review and commit themselves\n\n## Command Execution\n- Use `execute_command` with a single string containing command and arguments (e.g., `go run main.go`, `ls -la`, `cd /tmp`)\n- Use `cd /path` to change the working directory for file operations", "sys_prompt": "You are a software engineering assistant. Your goal is to help user with coding tasks, debugging, refactoring, and software development.\n\n## Core Principles\n1. **Security First**: Never expose secrets, keys, or credentials. Never commit sensitive data.\n2. **No Git Actions**: You can READ git info (status, log, diff) for context, but NEVER perform git actions (commit, add, push, checkout, reset, rm, etc.). Let the user handle all git operations.\n3. **Explore Before Execute**: Always understand the codebase structure before making changes.\n4. **Follow Conventions**: Match existing code style, patterns, and frameworks used in the project.\n5. **Be Concise**: Minimize output tokens while maintaining quality. Avoid unnecessary explanations.\n6. **Ask First**: When uncertain about intent, ask the user. Don't assume.\n\n## Workflow for Complex Tasks\nFor multi-step tasks, ALWAYS use the todo system to track progress:\n\n1. **Create Todo List**: At the start of complex tasks, use `todo create` to break down work into actionable items.\n2. **Update Progress**: Mark items as `in_progress` when working on them, and `completed` when done.\n3. **Check Status**: Use `todo read` to review your progress.\n\nExample workflow:\n- User: \"Add user authentication to this app\"\n- You: Create todos: [\"Analyze existing auth structure\", \"Check frameworks in use\", \"Implement auth middleware\", \"Add login endpoints\", \"Test implementation\"]\n\n## Task Execution Flow\n\n### Phase 1: Exploration (Always First)\n- Use `run \"ls\"` to understand directory structure\n- Use `run \"cat <file>\"` to examine relevant files\n- Use `run \"grep <pattern>\"` or `run \"find . -name *.go\"` to search for patterns\n- Check README, Makefile, go.mod for build/test commands\n- Identify: frameworks, conventions, testing approach, lint/typecheck commands\n- **Git reads allowed**: You may use `run \"git status\"`, `run \"git log\"`, `run \"git diff\"` for context\n- **Path handling**: Relative paths resolve against FilePickerDir; absolute paths (starting with `/`) bypass it\n\n### Phase 2: Planning\n- For complex tasks: create todo items\n- Identify files that need modification\n- Plan your approach following existing patterns\n\n### Phase 3: Implementation\n- Make changes using `run \"write <file> <content>\"` for new files or full rewrites. For small targeted changes, use `run \"sed -i 's/old/new/g' <file>\"`. For larger edits, read the file first and then use write to overwrite the entire file with updated content.\n- Follow existing code style exactly\n- Use existing libraries and utilities\n\n### Phase 4: Verification\n- Run tests if available (check for test commands in README/Makefile)\n- Run linting/type checking commands\n- Verify changes work as expected\n\n### Phase 5: Completion\n- Update todos to `completed`\n- Provide concise summary of changes\n- Reference specific file paths and line numbers when relevant\n- **DO NOT commit changes** - inform user what was done so they can review and commit themselves\n\n## Available Commands\n- `run \"ls [path]\"` - list files in directory\n- `run \"cat <file>\"` - read file content\n- `run \"write <file> <content>\"` - write/overwrite content to file\n- `run \"stat <file>\"` - get file info (size, type, modified)\n- `run \"rm <file>\"` - delete file\n- `run \"cp <src> <dst>\"` - copy file\n- `run \"mv <src> <dst>\"` - move/rename file\n- `run \"mkdir <dir>\"` - create directory\n- `run \"pwd\"` - print working directory\n- `run \"cd <dir>\"` - change directory\n- `run \"sed 's/old/new/' [file]\"` - text replacement (use -i for in-place editing)\n- `run \"grep <pattern> [file]\"` - filter lines\n- `run \"head [n] [file]\"` - show first n lines\n- `run \"tail [n] [file]\"` - show last n lines\n- `run \"wc [-l|-w|-c] [file]\"` - count lines/words/chars\n- `run \"sort [-r] [file]\"` - sort lines\n- `run \"uniq [file]\"` - remove duplicates\n- `run \"echo <text>\"` - echo back input\n- `run \"time\"` - show current time\n- `run \"go <cmd>\"` - go commands (run, build, test, mod, etc.)\n- `run \"git <cmd>\"` - git commands (status, log, diff, show, branch, etc.)\n- `run \"memory store <topic> <data>\"` - save to memory\n- `run \"memory get <topic>\"` - retrieve from memory\n- `run \"memory list\"` - list all topics\n- `run \"memory forget <topic>\"` - delete from memory\n- `run \"todo create <task>\"` - create a todo\n- `run \"todo read\"` - list all todos\n- `run \"todo update <id> <status>\"` - update todo\n- `run \"todo delete <id>\"` - delete a todo\n- `run \"view_img <file>\"` - view an image file\n\nUse: run \"command\" to execute. Supports chaining: cmd1 | cmd2, cmd1 && cmd2",
"role": "CodingAssistant", "role": "CodingAssistant",
"filepath": "sysprompts/coding_assistant.json", "filepath": "sysprompts/coding_assistant.json",
"first_msg": "Hello! I'm your coding assistant. Give me a specific task and I'll get started. For complex work, I'll track progress with todos." "first_msg": "Hello! I'm your coding assistant. Give me a specific task and I'll get started. For complex work, I'll track progress with todos."

383
tables.go
View File

@@ -273,7 +273,7 @@ func makeRAGTable(fileList []string, loadedFiles []string) *tview.Flex {
fileTable := tview.NewTable(). fileTable := tview.NewTable().
SetBorders(true) SetBorders(true)
longStatusView := tview.NewTextView() longStatusView := tview.NewTextView()
longStatusView.SetText("press x to exit") longStatusView.SetText("press x to exit | press d to view DB")
longStatusView.SetBorder(true).SetTitle("status") longStatusView.SetBorder(true).SetTitle("status")
longStatusView.SetChangedFunc(func() { longStatusView.SetChangedFunc(func() {
app.Draw() app.Draw()
@@ -498,6 +498,14 @@ func makeRAGTable(fileList []string, loadedFiles []string) *tview.Flex {
pages.RemovePage(RAGPage) pages.RemovePage(RAGPage)
return nil return nil
} }
if event.Key() == tcell.KeyRune && event.Rune() == 'd' {
pages.RemovePage(RAGPage)
dbTable := makeDbTable()
if dbTable != nil {
pages.AddPage(dbTablesPage, dbTable, true, true)
}
return nil
}
return event return event
}) })
return ragflex return ragflex
@@ -1189,3 +1197,376 @@ func makeFilePicker() *tview.Flex {
}) })
return flex return flex
} }
func makeDbTable() *tview.Flex {
tables, err := store.ListTables()
if err != nil {
logger.Error("failed to list tables", "error", err)
showToast("error", "failed to list tables: "+err.Error())
return nil
}
if len(tables) == 0 {
showToast("info", "no tables found in database")
return nil
}
tblList := tview.NewList().ShowSecondaryText(false)
rowCounts := make(map[string]int)
for _, t := range tables {
var count int
_ = store.DB().Get(&count, "SELECT COUNT(*) FROM "+t)
rowCounts[t] = count
tblList.AddItem(t, fmt.Sprintf("%d rows", count), 0, nil)
}
tblList.SetBorder(true).SetTitle("Tables")
dataTable := tview.NewTable().SetBorders(true)
dataTable.SetBorder(true).SetTitle("Data")
flex := tview.NewFlex().
AddItem(tblList, 0, 1, true).
AddItem(dataTable, 0, 2, false)
loadTableData := func(tableName string, tbl *tview.Table) {
rows, err := store.DB().Queryx("SELECT * FROM " + tableName + " LIMIT 80")
if err != nil {
logger.Error("failed to query table", "table", tableName, "error", err)
return
}
columnNames, _ := rows.Columns()
tbl.Clear()
for c, name := range columnNames {
tbl.SetCell(0, c,
tview.NewTableCell(name).
SetTextColor(tcell.ColorYellow).
SetAlign(tview.AlignCenter))
}
r := 1
for rows.Next() {
row := make(map[string]interface{})
if err := rows.MapScan(row); err != nil {
continue
}
for c, name := range columnNames {
val, ok := row[name]
var cellText string
var color tcell.Color
if !ok || val == nil {
cellText = "NULL"
color = tcell.ColorDarkGray
} else {
cellText = fmt.Sprintf("%v", val)
if len(cellText) > 30 {
cellText = cellText[:30] + "..."
}
color = tcell.ColorWhite
}
tbl.SetCell(r, c,
tview.NewTableCell(cellText).
SetTextColor(color).
SetAlign(tview.AlignCenter))
}
r++
}
rows.Close()
tbl.Select(0, 0)
}
tblList.SetSelectedFunc(func(idx int, mainText, secondaryText string, rune rune) {
if idx >= 0 && idx < len(tables) {
loadTableData(tables[idx], dataTable)
dataTable.SetBorder(true).SetTitle("Data: " + tables[idx])
}
})
tblList.SetChangedFunc(func(idx int, mainText, secondaryText string, rune rune) {
if idx >= 0 && idx < len(tables) {
loadTableData(tables[idx], dataTable)
dataTable.SetBorder(true).SetTitle("Data: " + tables[idx])
}
})
tblList.SetDoneFunc(func() {
pages.RemovePage(dbTablesPage)
})
tblList.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
if event.Key() == tcell.KeyRune && event.Rune() == 'x' {
pages.RemovePage(dbTablesPage)
app.SetFocus(textArea)
return nil
}
if event.Key() == tcell.KeyEnter {
idx := tblList.GetCurrentItem()
if idx >= 0 && idx < len(tables) {
showDbContentView(tables[idx])
}
return nil
}
return event
})
if len(tables) > 0 {
tblList.SetCurrentItem(0)
}
return flex
}
func updateColumnsView(tableName string, tbl *tview.Table) {
columns, err := store.GetTableColumns(tableName)
if err != nil {
logger.Error("failed to get table columns", "table", tableName, "error", err)
return
}
tbl.Clear()
cols := 5
tbl.SetFixed(1, 0)
for c := 0; c < cols; c++ {
color := tcell.ColorYellow
var headerText string
switch c {
case 0:
headerText = "CID"
case 1:
headerText = "Name"
case 2:
headerText = "Type"
case 3:
headerText = "NotNull"
case 4:
headerText = "PK"
}
tbl.SetCell(0, c,
tview.NewTableCell(headerText).
SetTextColor(color).
SetAlign(tview.AlignCenter).
SetSelectable(false))
}
for r, col := range columns {
for c := 0; c < cols; c++ {
color := tcell.ColorWhite
if col.PK > 0 {
color = tcell.ColorRed
}
switch c {
case 0:
tbl.SetCell(r+1, c,
tview.NewTableCell(fmt.Sprintf("%d", col.CID)).
SetTextColor(color).
SetAlign(tview.AlignCenter).
SetSelectable(false))
case 1:
tbl.SetCell(r+1, c,
tview.NewTableCell(col.Name).
SetTextColor(color).
SetAlign(tview.AlignCenter).
SetSelectable(false))
case 2:
tbl.SetCell(r+1, c,
tview.NewTableCell(col.Type).
SetTextColor(color).
SetAlign(tview.AlignCenter).
SetSelectable(false))
case 3:
notNull := "N"
if col.NotNull {
notNull = "Y"
}
tbl.SetCell(r+1, c,
tview.NewTableCell(notNull).
SetTextColor(color).
SetAlign(tview.AlignCenter).
SetSelectable(false))
case 4:
pk := ""
if col.PK > 0 {
pk = fmt.Sprintf("%d", col.PK)
}
tbl.SetCell(r+1, c,
tview.NewTableCell(pk).
SetTextColor(color).
SetAlign(tview.AlignCenter).
SetSelectable(false))
}
}
}
tbl.Select(0, 0)
}
func showDbColumnsView(tableName, parentPage string) {
longStatusView := tview.NewTextView()
longStatusView.SetText("table: " + tableName + " | press x to exit | press Enter to view content").SetBorder(true).SetTitle("status")
longStatusView.SetChangedFunc(func() {
app.Draw()
})
flex := tview.NewFlex().SetDirection(tview.FlexRow).
AddItem(longStatusView, 0, 10, false).
AddItem(tview.NewTable().SetBorders(true), 0, 60, true)
columns, err := store.GetTableColumns(tableName)
if err != nil {
logger.Error("failed to get table columns", "table", tableName, "error", err)
showToast("error", "failed to get columns: "+err.Error())
return
}
tbl := flex.GetItem(1).(*tview.Table)
cols := 5 // CID | Name | Type | NotNull | PK
tbl.SetFixed(1, 0)
for c := 0; c < cols; c++ {
color := tcell.ColorYellow
var headerText string
switch c {
case 0:
headerText = "CID"
case 1:
headerText = "Name"
case 2:
headerText = "Type"
case 3:
headerText = "NotNull"
case 4:
headerText = "PK"
}
tbl.SetCell(0, c,
tview.NewTableCell(headerText).
SetTextColor(color).
SetAlign(tview.AlignCenter).
SetSelectable(false))
}
for r, col := range columns {
for c := 0; c < cols; c++ {
color := tcell.ColorWhite
if col.PK > 0 {
color = tcell.ColorRed
}
switch c {
case 0:
tbl.SetCell(r+1, c,
tview.NewTableCell(fmt.Sprintf("%d", col.CID)).
SetTextColor(color).
SetAlign(tview.AlignCenter).
SetSelectable(false))
case 1:
tbl.SetCell(r+1, c,
tview.NewTableCell(col.Name).
SetTextColor(color).
SetAlign(tview.AlignCenter).
SetSelectable(false))
case 2:
tbl.SetCell(r+1, c,
tview.NewTableCell(col.Type).
SetTextColor(color).
SetAlign(tview.AlignCenter).
SetSelectable(false))
case 3:
notNull := "N"
if col.NotNull {
notNull = "Y"
}
tbl.SetCell(r+1, c,
tview.NewTableCell(notNull).
SetTextColor(color).
SetAlign(tview.AlignCenter).
SetSelectable(false))
case 4:
pk := ""
if col.PK > 0 {
pk = fmt.Sprintf("%d", col.PK)
}
tbl.SetCell(r+1, c,
tview.NewTableCell(pk).
SetTextColor(color).
SetAlign(tview.AlignCenter).
SetSelectable(false))
}
}
}
columnsPageName := "dbColumns"
pages.AddPage(columnsPageName, flex, true, true)
flex.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
if event.Key() == tcell.KeyRune && event.Rune() == 'x' {
pages.RemovePage(columnsPageName)
return nil
}
if event.Key() == tcell.KeyEnter {
pages.RemovePage(columnsPageName)
showDbContentView(tableName)
}
return event
})
}
func showDbContentView(tableName string) {
batchSize := 80
longStatusView := tview.NewTextView()
longStatusView.SetText("table: " + tableName + " | press Enter to load more").SetBorder(true).SetTitle("status")
longStatusView.SetChangedFunc(func() {
app.Draw()
})
tbl := tview.NewTable().SetBorders(true).SetFixed(1, 0)
flex := tview.NewFlex().SetDirection(tview.FlexRow).
AddItem(longStatusView, 0, 10, false).
AddItem(tbl, 0, 60, true)
contentPageName := "db_content_" + tableName
offset := 0
var rowCount int
_ = store.DB().Get(&rowCount, "SELECT COUNT(*) FROM "+tableName)
var columnNames []string
loadRows := func(off int) {
rows, err := store.DB().Queryx("SELECT * FROM " + tableName + " LIMIT " + fmt.Sprintf("%d", batchSize) + " OFFSET " + fmt.Sprintf("%d", off))
if err != nil {
logger.Error("failed to query table", "table", tableName, "error", err)
return
}
if off == 0 {
columnNames, _ = rows.Columns()
for c, name := range columnNames {
tbl.SetCell(0, c,
tview.NewTableCell(name).
SetTextColor(tcell.ColorYellow).
SetAlign(tview.AlignCenter).
SetSelectable(false))
}
}
r := off
for rows.Next() {
row := make(map[string]interface{})
if err := rows.MapScan(row); err != nil {
logger.Error("failed to scan row", "error", err)
continue
}
for c, name := range columnNames {
val, ok := row[name]
if !ok {
tbl.SetCell(r+1, c,
tview.NewTableCell("NULL").
SetTextColor(tcell.ColorDarkGray).
SetAlign(tview.AlignCenter).
SetSelectable(false))
} else {
str := fmt.Sprintf("%v", val)
if len(str) > 50 {
str = str[:50] + "..."
}
tbl.SetCell(r+1, c,
tview.NewTableCell(str).
SetTextColor(tcell.ColorWhite).
SetAlign(tview.AlignCenter).
SetSelectable(false))
}
}
r++
}
rows.Close()
loaded := tbl.GetRowCount() - 1
if loaded < rowCount {
longStatusView.SetText(fmt.Sprintf("table: %s | loaded %d of %d rows | press Enter for more", tableName, loaded, rowCount))
} else {
longStatusView.SetText(fmt.Sprintf("table: %s | loaded %d rows (all)", tableName, loaded))
}
}
loadRows(0)
pages.AddPage(contentPageName, flex, true, true)
flex.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
if event.Key() == tcell.KeyRune && event.Rune() == 'x' {
pages.RemovePage(contentPageName)
return nil
}
if event.Key() == tcell.KeyEnter {
offset += batchSize
loadRows(offset)
tbl.ScrollToEnd()
}
return event
})
}

View File

@@ -6,7 +6,6 @@ import (
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"strconv"
"strings" "strings"
) )
@@ -14,20 +13,24 @@ import (
type Operator int type Operator int
const ( const (
OpNone Operator = iota OpNone Operator = iota
OpAnd // && OpAnd // &&
OpOr // || OpOr // ||
OpSeq // ; OpSeq // ;
OpPipe // | OpPipe // |
OpRedirect // >
OpAppend // >>
) )
// Segment is a single command in a chain. // Segment is a single command in a chain.
type Segment struct { type Segment struct {
Raw string Raw string
Op Operator // operator AFTER this segment Op Operator // operator AFTER this segment
RedirectTo string // file path for > or >>
IsAppend bool // true for >>, false for >
} }
// ParseChain splits a command string into segments by &&, ;, and |. // ParseChain splits a command string into segments by &&, ;, |, >, and >>.
// Respects quoted strings (single and double quotes). // Respects quoted strings (single and double quotes).
func ParseChain(input string) []Segment { func ParseChain(input string) []Segment {
var segments []Segment var segments []Segment
@@ -36,6 +39,7 @@ func ParseChain(input string) []Segment {
n := len(runes) n := len(runes)
for i := 0; i < n; i++ { for i := 0; i < n; i++ {
ch := runes[i] ch := runes[i]
// handle quotes // handle quotes
if ch == '\'' || ch == '"' { if ch == '\'' || ch == '"' {
quote := ch quote := ch
@@ -50,6 +54,31 @@ func ParseChain(input string) []Segment {
} }
continue continue
} }
// >>
if ch == '>' && i+1 < n && runes[i+1] == '>' {
cmd := strings.TrimSpace(current.String())
if cmd != "" {
segments = append(segments, Segment{
Raw: cmd,
Op: OpAppend,
})
}
current.Reset()
i++ // skip second >
continue
}
// >
if ch == '>' {
cmd := strings.TrimSpace(current.String())
if cmd != "" {
segments = append(segments, Segment{
Raw: cmd,
Op: OpRedirect,
})
}
current.Reset()
continue
}
// && // &&
if ch == '&' && i+1 < n && runes[i+1] == '&' { if ch == '&' && i+1 < n && runes[i+1] == '&' {
segments = append(segments, Segment{ segments = append(segments, Segment{
@@ -105,6 +134,74 @@ func ExecChain(command string) string {
if len(segments) == 0 { if len(segments) == 0 {
return "[error] empty command" return "[error] empty command"
} }
// Check if we have a redirect
var redirectTo string
var isAppend bool
redirectIdx := -1
for i, seg := range segments {
if seg.Op == OpRedirect || seg.Op == OpAppend {
redirectIdx = i
isAppend = seg.Op == OpAppend
break
}
}
if redirectIdx >= 0 && redirectIdx+1 < len(segments) {
targetPath, err := resolveRedirectPath(segments[redirectIdx+1].Raw)
if err != nil {
return fmt.Sprintf("[error] redirect: %v", err)
}
redirectTo = targetPath
redirectCmd := segments[redirectIdx].Raw
// Check if there's chaining after redirect by looking at the operator of the segment AFTER the target
// The segment at redirectIdx+1 is the target file, and its Op tells us what operator follows
hasChainingAfterRedirect := false
if redirectIdx+1 < len(segments) && segments[redirectIdx+1].Op != OpNone && segments[redirectIdx+1].Op != OpPipe {
hasChainingAfterRedirect = true
}
// Remove both the redirect segment and its target
segments = append(segments[:redirectIdx], segments[redirectIdx+2:]...)
// Execute the redirect command
var lastOutput string
var lastErr error
lastOutput, lastErr = execSingle(redirectCmd, "")
if lastErr != nil {
return fmt.Sprintf("[error] redirect: %v", lastErr)
}
if err := writeFile(redirectTo, lastOutput, isAppend); err != nil {
return fmt.Sprintf("[error] redirect: %v", err)
}
mode := "Wrote"
if isAppend {
mode = "Appended"
}
size := humanSizeChain(int64(len(lastOutput)))
redirectResult := fmt.Sprintf("%s %s → %s", mode, size, filepath.Base(redirectTo))
// If no remaining segments or no chaining, just return the write confirmation
if len(segments) == 0 || !hasChainingAfterRedirect {
return redirectResult
}
// There are remaining commands after the redirect
collected := []string{redirectResult}
// Execute remaining commands
for _, seg := range segments {
lastOutput, lastErr = execSingle(seg.Raw, "")
if lastOutput != "" {
collected = append(collected, lastOutput)
}
}
return strings.Join(collected, "\n")
} else if redirectIdx >= 0 && redirectIdx+1 >= len(segments) {
return "[error] redirect: target file required"
}
var collected []string var collected []string
var lastOutput string var lastOutput string
var lastErr error var lastErr error
@@ -112,16 +209,13 @@ func ExecChain(command string) string {
for i, seg := range segments { for i, seg := range segments {
if i > 0 { if i > 0 {
prevOp := segments[i-1].Op prevOp := segments[i-1].Op
// && semantics: skip if previous failed
if prevOp == OpAnd && lastErr != nil { if prevOp == OpAnd && lastErr != nil {
continue continue
} }
// || semantics: skip if previous succeeded
if prevOp == OpOr && lastErr == nil { if prevOp == OpOr && lastErr == nil {
continue continue
} }
} }
// determine stdin for this segment
segStdin := "" segStdin := ""
if i == 0 { if i == 0 {
segStdin = pipeInput segStdin = pipeInput
@@ -129,8 +223,6 @@ func ExecChain(command string) string {
segStdin = lastOutput segStdin = lastOutput
} }
lastOutput, lastErr = execSingle(seg.Raw, segStdin) lastOutput, lastErr = execSingle(seg.Raw, segStdin)
// pipe: output flows to next command's stdin
// && or ;: collect output
if i < len(segments)-1 && seg.Op == OpPipe { if i < len(segments)-1 && seg.Op == OpPipe {
continue continue
} }
@@ -138,6 +230,21 @@ func ExecChain(command string) string {
collected = append(collected, lastOutput) collected = append(collected, lastOutput)
} }
} }
// Handle redirect if present
if redirectTo != "" {
output := lastOutput
if err := writeFile(redirectTo, output, isAppend); err != nil {
return fmt.Sprintf("[error] redirect: %v", err)
}
mode := "Wrote"
if isAppend {
mode = "Appended"
}
size := humanSizeChain(int64(len(output)))
return fmt.Sprintf("%s %s → %s", mode, size, filepath.Base(redirectTo))
}
return strings.Join(collected, "\n") return strings.Join(collected, "\n")
} }
@@ -150,19 +257,25 @@ func execSingle(command, stdin string) (string, error) {
name := parts[0] name := parts[0]
args := parts[1:] args := parts[1:]
// Check if it's a built-in Go command // Check if it's a built-in Go command
if result, isBuiltin := execBuiltin(name, args, stdin); isBuiltin { result, err := execBuiltin(name, args, stdin)
if err == nil {
return result, nil return result, nil
} }
// Otherwise execute as system command // Check if it's a "not a builtin" error (meaning we should try system command)
cmd := exec.Command(name, args...) if err.Error() == "not a builtin" {
if stdin != "" { // Execute as system command
cmd.Stdin = strings.NewReader(stdin) cmd := exec.Command(name, args...)
if stdin != "" {
cmd.Stdin = strings.NewReader(stdin)
}
output, err := cmd.CombinedOutput()
if err != nil {
return string(output), err
}
return string(output), nil
} }
output, err := cmd.CombinedOutput() // It's a builtin that returned an error
if err != nil { return result, err
return string(output), err
}
return string(output), nil
} }
// tokenize splits a command string by whitespace, respecting quotes. // tokenize splits a command string by whitespace, respecting quotes.
@@ -201,216 +314,103 @@ func tokenize(input string) []string {
} }
// execBuiltin executes a built-in command if it exists. // execBuiltin executes a built-in command if it exists.
// Returns (result, true) if it was a built-in (even if result is empty). func execBuiltin(name string, args []string, stdin string) (string, error) {
// Returns ("", false) if it's not a built-in command. var result string
func execBuiltin(name string, args []string, stdin string) (string, bool) {
switch name { switch name {
case "echo": case "echo":
if stdin != "" { result = FsEcho(args, stdin)
return stdin, true
}
return strings.Join(args, " "), true
case "time": case "time":
return "2006-01-02 15:04:05 MST", true result = FsTime(args, stdin)
case "cat": case "cat":
if len(args) == 0 { result = FsCat(args, stdin)
if stdin != "" {
return stdin, true
}
return "", true
}
path := args[0]
abs := path
if !filepath.IsAbs(path) {
abs = filepath.Join(cfg.FilePickerDir, path)
}
data, err := os.ReadFile(abs)
if err != nil {
return fmt.Sprintf("[error] cat: %v", err), true
}
return string(data), true
case "pwd": case "pwd":
return cfg.FilePickerDir, true result = FsPwd(args, stdin)
case "cd": case "cd":
if len(args) == 0 { result = FsCd(args, stdin)
return "[error] usage: cd <dir>", true
}
dir := args[0]
// Resolve relative to cfg.FilePickerDir
abs := dir
if !filepath.IsAbs(dir) {
abs = filepath.Join(cfg.FilePickerDir, dir)
}
abs = filepath.Clean(abs)
info, err := os.Stat(abs)
if err != nil {
return fmt.Sprintf("[error] cd: %v", err), true
}
if !info.IsDir() {
return "[error] cd: not a directory: " + dir, true
}
cfg.FilePickerDir = abs
return "Changed directory to: " + cfg.FilePickerDir, true
case "mkdir": case "mkdir":
if len(args) == 0 { result = FsMkdir(args, stdin)
return "[error] usage: mkdir [-p] <dir>", true
}
createParents := false
var dirPath string
for _, a := range args {
if a == "-p" || a == "--parents" {
createParents = true
} else if dirPath == "" {
dirPath = a
}
}
if dirPath == "" {
return "[error] usage: mkdir [-p] <dir>", true
}
abs := dirPath
if !filepath.IsAbs(dirPath) {
abs = filepath.Join(cfg.FilePickerDir, dirPath)
}
abs = filepath.Clean(abs)
var mkdirFunc func(string, os.FileMode) error
if createParents {
mkdirFunc = os.MkdirAll
} else {
mkdirFunc = os.Mkdir
}
if err := mkdirFunc(abs, 0o755); err != nil {
return fmt.Sprintf("[error] mkdir: %v", err), true
}
if createParents {
return "Created " + dirPath + " (with parents)", true
}
return "Created " + dirPath, true
case "ls": case "ls":
dir := "." result = FsLs(args, stdin)
for _, a := range args { case "cp":
if !strings.HasPrefix(a, "-") { result = FsCp(args, stdin)
dir = a case "mv":
break result = FsMv(args, stdin)
} case "rm":
} result = FsRm(args, stdin)
abs := dir case "grep":
if !filepath.IsAbs(dir) { result = FsGrep(args, stdin)
abs = filepath.Join(cfg.FilePickerDir, dir) case "head":
} result = FsHead(args, stdin)
entries, err := os.ReadDir(abs) case "tail":
if err != nil { result = FsTail(args, stdin)
return fmt.Sprintf("[error] ls: %v", err), true case "wc":
} result = FsWc(args, stdin)
var out strings.Builder case "sort":
for _, e := range entries { result = FsSort(args, stdin)
info, _ := e.Info() case "uniq":
switch { result = FsUniq(args, stdin)
case e.IsDir(): case "sed":
fmt.Fprintf(&out, "d %-8s %s/\n", "-", e.Name()) result = FsSed(args, stdin)
case info != nil: case "stat":
size := info.Size() result = FsStat(args, stdin)
sizeStr := strconv.FormatInt(size, 10)
if size > 1024 {
sizeStr = fmt.Sprintf("%.1fKB", float64(size)/1024)
}
fmt.Fprintf(&out, "f %-8s %s\n", sizeStr, e.Name())
default:
fmt.Fprintf(&out, "f %-8s %s\n", "?", e.Name())
}
}
if out.Len() == 0 {
return "(empty directory)", true
}
return strings.TrimRight(out.String(), "\n"), true
case "go": case "go":
// Allow all go subcommands
if len(args) == 0 { if len(args) == 0 {
return "[error] usage: go <subcommand> [options]", true return "[error] usage: go <subcommand> [options]", nil
} }
cmd := exec.Command("go", args...) cmd := exec.Command("go", args...)
cmd.Dir = cfg.FilePickerDir cmd.Dir = cfg.FilePickerDir
output, err := cmd.CombinedOutput() output, err := cmd.CombinedOutput()
if err != nil { if err != nil {
return fmt.Sprintf("[error] go %s: %v\n%s", args[0], err, string(output)), true return fmt.Sprintf("[error] go %s: %v\n%s", args[0], err, string(output)), nil
} }
return string(output), true return string(output), nil
case "cp": default:
if len(args) < 2 { return "", errors.New("not a builtin")
return "[error] usage: cp <source> <dest>", true }
} if strings.HasPrefix(result, "[error]") {
src := args[0] return result, errors.New(result)
dst := args[1] }
if !filepath.IsAbs(src) { return result, nil
src = filepath.Join(cfg.FilePickerDir, src) }
}
if !filepath.IsAbs(dst) { // resolveRedirectPath resolves the target path for a redirect operator
dst = filepath.Join(cfg.FilePickerDir, dst) func resolveRedirectPath(path string) (string, error) {
} path = strings.TrimSpace(path)
data, err := os.ReadFile(src) if path == "" {
if err != nil { return "", errors.New("redirect target required")
return fmt.Sprintf("[error] cp: %v", err), true }
} abs, err := resolvePath(path)
err = os.WriteFile(dst, data, 0644) if err != nil {
if err != nil { return "", err
return fmt.Sprintf("[error] cp: %v", err), true }
} return abs, nil
return "Copied " + src + " to " + dst, true }
case "mv":
if len(args) < 2 { // writeFile writes content to a file (truncate or append)
return "[error] usage: mv <source> <dest>", true func writeFile(path, content string, append bool) error {
} flags := os.O_CREATE | os.O_WRONLY
src := args[0] if append {
dst := args[1] flags |= os.O_APPEND
if !filepath.IsAbs(src) { } else {
src = filepath.Join(cfg.FilePickerDir, src) flags |= os.O_TRUNC
} }
if !filepath.IsAbs(dst) { f, err := os.OpenFile(path, flags, 0644)
dst = filepath.Join(cfg.FilePickerDir, dst) if err != nil {
} return err
err := os.Rename(src, dst) }
if err != nil { defer f.Close()
return fmt.Sprintf("[error] mv: %v", err), true _, err = f.WriteString(content)
} return err
return "Moved " + src + " to " + dst, true }
case "rm":
if len(args) == 0 { // humanSizeChain returns human-readable file size
return "[error] usage: rm [-r] <file>", true func humanSizeChain(n int64) string {
} switch {
recursive := false case n >= 1<<20:
var target string return fmt.Sprintf("%.1fMB", float64(n)/float64(1<<20))
for _, a := range args { case n >= 1<<10:
if a == "-r" || a == "-rf" || a == "-fr" || a == "-recursive" { return fmt.Sprintf("%.1fKB", float64(n)/float64(1<<10))
recursive = true default:
} else if target == "" { return fmt.Sprintf("%dB", n)
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 "", false
} }

View File

@@ -9,6 +9,7 @@ import (
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"regexp"
"sort" "sort"
"strconv" "strconv"
"strings" "strings"
@@ -61,16 +62,17 @@ func resolvePath(rel string) (string, error) {
if cfg.FilePickerDir == "" { if cfg.FilePickerDir == "" {
return "", errors.New("fs root not set") return "", errors.New("fs root not set")
} }
if filepath.IsAbs(rel) { isAbs := filepath.IsAbs(rel)
if isAbs {
abs := filepath.Clean(rel) abs := filepath.Clean(rel)
if !strings.HasPrefix(abs, cfg.FilePickerDir+string(os.PathSeparator)) && abs != cfg.FilePickerDir { if !cfg.FSAllowOutOfRoot && !strings.HasPrefix(abs, cfg.FilePickerDir+string(os.PathSeparator)) && abs != cfg.FilePickerDir {
return "", fmt.Errorf("path escapes fs root: %s", rel) return "", fmt.Errorf("path escapes fs root: %s", rel)
} }
return abs, nil return abs, nil
} }
abs := filepath.Join(cfg.FilePickerDir, rel) abs := filepath.Join(cfg.FilePickerDir, rel)
abs = filepath.Clean(abs) abs = filepath.Clean(abs)
if !strings.HasPrefix(abs, cfg.FilePickerDir+string(os.PathSeparator)) && abs != cfg.FilePickerDir { if !cfg.FSAllowOutOfRoot && !strings.HasPrefix(abs, cfg.FilePickerDir+string(os.PathSeparator)) && abs != cfg.FilePickerDir {
return "", fmt.Errorf("path escapes fs root: %s", rel) return "", fmt.Errorf("path escapes fs root: %s", rel)
} }
return abs, nil return abs, nil
@@ -93,10 +95,77 @@ func IsImageFile(path string) bool {
} }
func FsLs(args []string, stdin string) string { func FsLs(args []string, stdin string) string {
showAll := false
longFormat := false
dir := "" dir := ""
if len(args) > 0 { for _, a := range args {
dir = args[0] if strings.HasPrefix(a, "-") && !strings.HasPrefix(a, "--") {
flags := strings.TrimLeft(a, "-")
for _, c := range flags {
switch c {
case 'a':
showAll = true
case 'l':
longFormat = true
}
}
} else if a != "" && dir == "" {
dir = a
}
} }
hasGlob := strings.ContainsAny(dir, "*?[")
if hasGlob {
absDir := cfg.FilePickerDir
if filepath.IsAbs(dir) {
absDir = filepath.Dir(dir)
} else if strings.Contains(dir, "/") {
absDir = filepath.Join(cfg.FilePickerDir, filepath.Dir(dir))
}
globPattern := filepath.Base(dir)
fullPattern := filepath.Join(absDir, globPattern)
matches, err := filepath.Glob(fullPattern)
if err != nil {
return fmt.Sprintf("[error] ls: %v", err)
}
if len(matches) == 0 {
return "[error] ls: no such file or directory"
}
var out strings.Builder
filter := func(name string) bool {
return showAll || !strings.HasPrefix(name, ".")
}
for _, match := range matches {
info, err := os.Stat(match)
if err != nil {
continue
}
name := filepath.Base(match)
if !filter(name) {
continue
}
if longFormat {
if info.IsDir() {
fmt.Fprintf(&out, "d %-8s %s/\n", "-", name)
} else {
fmt.Fprintf(&out, "f %-8s %s\n", humanSize(info.Size()), name)
}
} else {
if info.IsDir() {
fmt.Fprintf(&out, "%s/\n", name)
} else {
fmt.Fprintf(&out, "%s\n", name)
}
}
}
if out.Len() == 0 {
return "(empty directory)"
}
return strings.TrimRight(out.String(), "\n")
}
abs, err := resolvePath(dir) abs, err := resolvePath(dir)
if err != nil { if err != nil {
return fmt.Sprintf("[error] %v", err) return fmt.Sprintf("[error] %v", err)
@@ -106,15 +175,29 @@ func FsLs(args []string, stdin string) string {
return fmt.Sprintf("[error] ls: %v", err) return fmt.Sprintf("[error] ls: %v", err)
} }
var out strings.Builder var out strings.Builder
filter := func(name string) bool {
return showAll || !strings.HasPrefix(name, ".")
}
for _, e := range entries { for _, e := range entries {
name := e.Name()
if !filter(name) {
continue
}
info, _ := e.Info() info, _ := e.Info()
switch { if longFormat {
case e.IsDir(): if e.IsDir() {
fmt.Fprintf(&out, "d %-8s %s/\n", "-", e.Name()) fmt.Fprintf(&out, "d %-8s %s/\n", "-", name)
case info != nil: } else if info != nil {
fmt.Fprintf(&out, "f %-8s %s\n", humanSize(info.Size()), e.Name()) fmt.Fprintf(&out, "f %-8s %s\n", humanSize(info.Size()), name)
default: } else {
fmt.Fprintf(&out, "f %-8s %s\n", "?", e.Name()) fmt.Fprintf(&out, "f %-8s %s\n", "?", name)
}
} else {
if e.IsDir() {
fmt.Fprintf(&out, "%s/\n", name)
} else {
fmt.Fprintf(&out, "%s\n", name)
}
} }
} }
if out.Len() == 0 { if out.Len() == 0 {
@@ -125,33 +208,59 @@ func FsLs(args []string, stdin string) string {
func FsCat(args []string, stdin string) string { func FsCat(args []string, stdin string) string {
b64 := false b64 := false
var path string var paths []string
for _, a := range args { for _, a := range args {
if a == "-b" || a == "--base64" { if a == "-b" || a == "--base64" {
b64 = true b64 = true
} else if path == "" { } else if a != "" {
path = a paths = append(paths, a)
} }
} }
if path == "" { if len(paths) == 0 {
return "[error] usage: cat <path>" if stdin != "" {
} return stdin
abs, err := resolvePath(path)
if err != nil {
return fmt.Sprintf("[error] %v", err)
}
data, err := os.ReadFile(abs)
if err != nil {
return fmt.Sprintf("[error] cat: %v", err)
}
if b64 {
result := base64.StdEncoding.EncodeToString(data)
if IsImageFile(path) {
result += fmt.Sprintf("\n![image](file://%s)", abs)
} }
return result return "[error] usage: cat <path> or cat (with stdin)"
} }
return string(data)
var allFiles []string
for _, path := range paths {
if strings.ContainsAny(path, "*?[") {
matches, err := filepath.Glob(path)
if err != nil {
return fmt.Sprintf("[error] cat: %v", err)
}
allFiles = append(allFiles, matches...)
} else {
allFiles = append(allFiles, path)
}
}
if len(allFiles) == 0 {
return "[error] cat: no files found"
}
var results []string
for _, path := range allFiles {
abs, err := resolvePath(path)
if err != nil {
return fmt.Sprintf("[error] %v", err)
}
data, err := os.ReadFile(abs)
if err != nil {
return fmt.Sprintf("[error] cat: %v", err)
}
if b64 {
result := base64.StdEncoding.EncodeToString(data)
if IsImageFile(path) {
result += fmt.Sprintf("\n![image](file://%s)", abs)
}
results = append(results, result)
} else {
results = append(results, string(data))
}
}
return strings.Join(results, "")
} }
func FsViewImg(args []string, stdin string) string { func FsViewImg(args []string, stdin string) string {
@@ -193,11 +302,6 @@ func FsViewImg(args []string, stdin string) string {
return string(jsonResult) return string(jsonResult)
} }
// FsSee is deprecated, use FsViewImg
func FsSee(args []string, stdin string) string {
return FsViewImg(args, stdin)
}
func FsWrite(args []string, stdin string) string { func FsWrite(args []string, stdin string) string {
b64 := false b64 := false
var path string var path string
@@ -297,60 +401,211 @@ func FsRm(args []string, stdin string) string {
if len(args) == 0 { if len(args) == 0 {
return "[error] usage: rm <path>" return "[error] usage: rm <path>"
} }
abs, err := resolvePath(args[0]) force := false
if err != nil { var paths []string
return fmt.Sprintf("[error] %v", err) for _, a := range args {
if a == "-f" || a == "--force" {
force = true
} else if !strings.HasPrefix(a, "-") {
paths = append(paths, a)
}
} }
if err := os.RemoveAll(abs); err != nil { if len(paths) == 0 {
return fmt.Sprintf("[error] rm: %v", err) return "[error] usage: rm <path>"
} }
return "Removed " + args[0]
var removed []string
var errs []string
for _, path := range paths {
if strings.ContainsAny(path, "*?[") {
matches, err := filepath.Glob(path)
if err != nil {
if !force {
return fmt.Sprintf("[error] rm: %v", err)
}
continue
}
for _, m := range matches {
if err := os.RemoveAll(m); err != nil {
if !force {
errs = append(errs, fmt.Sprintf("%v", err))
}
continue
}
removed = append(removed, m)
}
} else {
abs, err := resolvePath(path)
if err != nil {
if !force {
return fmt.Sprintf("[error] %v", err)
}
continue
}
if err := os.RemoveAll(abs); err != nil {
if !force {
return fmt.Sprintf("[error] rm: %v", err)
}
continue
}
removed = append(removed, path)
}
}
if len(removed) == 0 && len(errs) > 0 {
return "[error] rm: " + strings.Join(errs, "; ")
}
return "Removed " + strings.Join(removed, ", ")
} }
func FsCp(args []string, stdin string) string { func FsCp(args []string, stdin string) string {
if len(args) < 2 { if len(args) < 2 {
return "[error] usage: cp <src> <dst>" return "[error] usage: cp <src> <dst>"
} }
srcAbs, err := resolvePath(args[0]) srcPattern := args[0]
if err != nil { dstPath := args[1]
return fmt.Sprintf("[error] %v", err)
// Check if dst is an existing directory (ends with / or is a directory)
dstIsDir := strings.HasSuffix(dstPath, "/")
if !dstIsDir {
if info, err := os.Stat(dstPath); err == nil && info.IsDir() {
dstIsDir = true
}
} }
dstAbs, err := resolvePath(args[1])
if err != nil { // Check for single file copy (no glob and dst doesn't end with / and is not an existing dir)
return fmt.Sprintf("[error] %v", err) hasGlob := strings.ContainsAny(srcPattern, "*?[")
// Single source file to a specific file path (not a glob, not a directory)
if !hasGlob && !dstIsDir {
// Check if destination is an existing file - if not, treat as single file copy
if info, err := os.Stat(dstPath); err != nil || !info.IsDir() {
srcAbs, err := resolvePath(srcPattern)
if err != nil {
return fmt.Sprintf("[error] %v", err)
}
data, err := os.ReadFile(srcAbs)
if err != nil {
return fmt.Sprintf("[error] cp read: %v", err)
}
if err := os.WriteFile(dstPath, data, 0o644); err != nil {
return fmt.Sprintf("[error] cp write: %v", err)
}
return fmt.Sprintf("Copied %s → %s (%s)", srcPattern, dstPath, humanSize(int64(len(data))))
}
} }
data, err := os.ReadFile(srcAbs)
if err != nil { // Copy to directory (either glob, or explicit directory)
return fmt.Sprintf("[error] cp read: %v", err) var srcFiles []string
if hasGlob {
matches, err := filepath.Glob(srcPattern)
if err != nil {
return fmt.Sprintf("[error] cp: %v", err)
}
if len(matches) == 0 {
return "[error] cp: no files match pattern"
}
srcFiles = matches
} else {
srcFiles = []string{srcPattern}
} }
if err := os.MkdirAll(filepath.Dir(dstAbs), 0o755); err != nil {
return fmt.Sprintf("[error] cp mkdir: %v", err) var results []string
for _, srcPath := range srcFiles {
srcAbs, err := resolvePath(srcPath)
if err != nil {
return fmt.Sprintf("[error] %v", err)
}
data, err := os.ReadFile(srcAbs)
if err != nil {
return fmt.Sprintf("[error] cp read: %v", err)
}
dstAbs, err := resolvePath(filepath.Join(dstPath, filepath.Base(srcPath)))
if err != nil {
return fmt.Sprintf("[error] %v", err)
}
if err := os.MkdirAll(filepath.Dir(dstAbs), 0o755); err != nil {
return fmt.Sprintf("[error] cp mkdir: %v", err)
}
if err := os.WriteFile(dstAbs, data, 0o644); err != nil {
return fmt.Sprintf("[error] cp write: %v", err)
}
results = append(results, fmt.Sprintf("%s → %s (%s)", srcPath, filepath.Join(dstPath, filepath.Base(srcPath)), humanSize(int64(len(data)))))
} }
if err := os.WriteFile(dstAbs, data, 0o644); err != nil { return strings.Join(results, ", ")
return fmt.Sprintf("[error] cp write: %v", err)
}
return fmt.Sprintf("Copied %s → %s (%s)", args[0], args[1], humanSize(int64(len(data))))
} }
func FsMv(args []string, stdin string) string { func FsMv(args []string, stdin string) string {
if len(args) < 2 { if len(args) < 2 {
return "[error] usage: mv <src> <dst>" return "[error] usage: mv <src> <dst>"
} }
srcAbs, err := resolvePath(args[0]) srcPattern := args[0]
if err != nil { dstPath := args[1]
return fmt.Sprintf("[error] %v", err)
// Check if dst is an existing directory (ends with / or is a directory)
dstIsDir := strings.HasSuffix(dstPath, "/")
if !dstIsDir {
if info, err := os.Stat(dstPath); err == nil && info.IsDir() {
dstIsDir = true
}
} }
dstAbs, err := resolvePath(args[1])
if err != nil { // Check for single file move (no glob and dst doesn't end with / and is not an existing dir)
return fmt.Sprintf("[error] %v", err) hasGlob := strings.ContainsAny(srcPattern, "*?[")
// Single source file to a specific file path (not a glob, not a directory)
if !hasGlob && !dstIsDir {
// Check if destination is an existing file - if not, treat as single file move
if info, err := os.Stat(dstPath); err != nil || !info.IsDir() {
srcAbs, err := resolvePath(srcPattern)
if err != nil {
return fmt.Sprintf("[error] %v", err)
}
if err := os.MkdirAll(filepath.Dir(dstPath), 0o755); err != nil {
return fmt.Sprintf("[error] mv mkdir: %v", err)
}
if err := os.Rename(srcAbs, dstPath); err != nil {
return fmt.Sprintf("[error] mv: %v", err)
}
return fmt.Sprintf("Moved %s → %s", srcPattern, dstPath)
}
} }
if err := os.MkdirAll(filepath.Dir(dstAbs), 0o755); err != nil {
return fmt.Sprintf("[error] mv mkdir: %v", err) // Move to directory (either glob, or explicit directory)
var srcFiles []string
if hasGlob {
matches, err := filepath.Glob(srcPattern)
if err != nil {
return fmt.Sprintf("[error] mv: %v", err)
}
if len(matches) == 0 {
return "[error] mv: no files match pattern"
}
srcFiles = matches
} else {
srcFiles = []string{srcPattern}
} }
if err := os.Rename(srcAbs, dstAbs); err != nil {
return fmt.Sprintf("[error] mv: %v", err) var results []string
for _, srcPath := range srcFiles {
srcAbs, err := resolvePath(srcPath)
if err != nil {
return fmt.Sprintf("[error] %v", err)
}
dstAbs, err := resolvePath(filepath.Join(dstPath, filepath.Base(srcPath)))
if err != nil {
return fmt.Sprintf("[error] %v", err)
}
if err := os.MkdirAll(filepath.Dir(dstAbs), 0o755); err != nil {
return fmt.Sprintf("[error] mv mkdir: %v", err)
}
if err := os.Rename(srcAbs, dstAbs); err != nil {
return fmt.Sprintf("[error] mv: %v", err)
}
results = append(results, fmt.Sprintf("%s → %s", srcPath, filepath.Join(dstPath, filepath.Base(srcPath))))
} }
return fmt.Sprintf("Moved %s → %s", args[0], args[1]) return strings.Join(results, ", ")
} }
func FsMkdir(args []string, stdin string) string { func FsMkdir(args []string, stdin string) string {
@@ -394,7 +649,11 @@ func FsEcho(args []string, stdin string) string {
if stdin != "" { if stdin != "" {
return stdin return stdin
} }
return strings.Join(args, " ") result := strings.Join(args, " ")
if result != "" {
result += "\n"
}
return result
} }
func FsTime(args []string, stdin string) string { func FsTime(args []string, stdin string) string {
@@ -403,13 +662,31 @@ func FsTime(args []string, stdin string) string {
func FsGrep(args []string, stdin string) string { func FsGrep(args []string, stdin string) string {
if len(args) == 0 { if len(args) == 0 {
return "[error] usage: grep [-i] [-v] [-c] <pattern>" return "[error] usage: grep [-i] [-v] [-c] [-E] <pattern> [file]"
} }
ignoreCase := false ignoreCase := false
invert := false invert := false
countOnly := false countOnly := false
useRegex := false
var pattern string var pattern string
var filePath string
for _, a := range args { for _, a := range args {
if strings.HasPrefix(a, "-") && !strings.HasPrefix(a, "--") && len(a) > 1 {
flags := strings.TrimLeft(a, "-")
for _, c := range flags {
switch c {
case 'i':
ignoreCase = true
case 'v':
invert = true
case 'c':
countOnly = true
case 'E':
useRegex = true
}
}
continue
}
switch a { switch a {
case "-i": case "-i":
ignoreCase = true ignoreCase = true
@@ -417,24 +694,61 @@ func FsGrep(args []string, stdin string) string {
invert = true invert = true
case "-c": case "-c":
countOnly = true countOnly = true
case "-E":
useRegex = true
default: default:
pattern = a if pattern == "" {
pattern = a
} else if filePath == "" {
filePath = a
}
} }
} }
if pattern == "" { if pattern == "" {
return "[error] pattern required" return "[error] pattern required"
} }
if ignoreCase { var lines []string
pattern = strings.ToLower(pattern) if filePath != "" {
abs, err := resolvePath(filePath)
if err != nil {
return fmt.Sprintf("[error] grep: %v", err)
}
data, err := os.ReadFile(abs)
if err != nil {
return fmt.Sprintf("[error] grep: %v", err)
}
lines = strings.Split(string(data), "\n")
} else if stdin != "" {
lines = strings.Split(stdin, "\n")
} else {
return "[error] grep: no input (use file path or pipe from stdin)"
} }
lines := strings.Split(stdin, "\n")
var matched []string var matched []string
for _, line := range lines { for _, line := range lines {
haystack := line var match bool
if ignoreCase { if useRegex {
haystack = strings.ToLower(line) re, err := regexp.Compile(pattern)
if err != nil {
return fmt.Sprintf("[error] grep: invalid regex: %v", err)
}
match = re.MatchString(line)
if ignoreCase && !match {
reIC, err := regexp.Compile("(?i)" + pattern)
if err == nil {
match = reIC.MatchString(line)
}
}
} else {
haystack := line
if ignoreCase {
haystack = strings.ToLower(line)
patternLower := strings.ToLower(pattern)
match = strings.Contains(haystack, patternLower)
} else {
match = strings.Contains(haystack, pattern)
}
} }
match := strings.Contains(haystack, pattern)
if invert { if invert {
match = !match match = !match
} }
@@ -450,6 +764,7 @@ func FsGrep(args []string, stdin string) string {
func FsHead(args []string, stdin string) string { func FsHead(args []string, stdin string) string {
n := 10 n := 10
var filePath string
for i, a := range args { for i, a := range args {
if a == "-n" && i+1 < len(args) { if a == "-n" && i+1 < len(args) {
if parsed, err := strconv.Atoi(args[i+1]); err == nil { if parsed, err := strconv.Atoi(args[i+1]); err == nil {
@@ -459,9 +774,26 @@ func FsHead(args []string, stdin string) string {
continue continue
} else if parsed, err := strconv.Atoi(a); err == nil { } else if parsed, err := strconv.Atoi(a); err == nil {
n = parsed n = parsed
} else if filePath == "" && !strings.HasPrefix(a, "-") {
filePath = a
} }
} }
lines := strings.Split(stdin, "\n") var lines []string
if filePath != "" {
abs, err := resolvePath(filePath)
if err != nil {
return fmt.Sprintf("[error] head: %v", err)
}
data, err := os.ReadFile(abs)
if err != nil {
return fmt.Sprintf("[error] head: %v", err)
}
lines = strings.Split(string(data), "\n")
} else if stdin != "" {
lines = strings.Split(stdin, "\n")
} else {
return "[error] head: no input (use file path or pipe from stdin)"
}
if n > 0 && len(lines) > n { if n > 0 && len(lines) > n {
lines = lines[:n] lines = lines[:n]
} }
@@ -470,6 +802,7 @@ func FsHead(args []string, stdin string) string {
func FsTail(args []string, stdin string) string { func FsTail(args []string, stdin string) string {
n := 10 n := 10
var filePath string
for i, a := range args { for i, a := range args {
if a == "-n" && i+1 < len(args) { if a == "-n" && i+1 < len(args) {
if parsed, err := strconv.Atoi(args[i+1]); err == nil { if parsed, err := strconv.Atoi(args[i+1]); err == nil {
@@ -479,9 +812,29 @@ func FsTail(args []string, stdin string) string {
continue continue
} else if parsed, err := strconv.Atoi(a); err == nil { } else if parsed, err := strconv.Atoi(a); err == nil {
n = parsed n = parsed
} else if filePath == "" && !strings.HasPrefix(a, "-") {
filePath = a
} }
} }
lines := strings.Split(stdin, "\n") var lines []string
if filePath != "" {
abs, err := resolvePath(filePath)
if err != nil {
return fmt.Sprintf("[error] tail: %v", err)
}
data, err := os.ReadFile(abs)
if err != nil {
return fmt.Sprintf("[error] tail: %v", err)
}
lines = strings.Split(string(data), "\n")
} else if stdin != "" {
lines = strings.Split(stdin, "\n")
} else {
return "[error] tail: no input (use file path or pipe from stdin)"
}
for len(lines) > 0 && lines[len(lines)-1] == "" {
lines = lines[:len(lines)-1]
}
if n > 0 && len(lines) > n { if n > 0 && len(lines) > n {
lines = lines[len(lines)-n:] lines = lines[len(lines)-n:]
} }
@@ -489,9 +842,35 @@ func FsTail(args []string, stdin string) string {
} }
func FsWc(args []string, stdin string) string { func FsWc(args []string, stdin string) string {
lines := len(strings.Split(stdin, "\n")) var content string
words := len(strings.Fields(stdin)) var filePath string
chars := len(stdin) for _, a := range args {
if strings.HasPrefix(a, "-") {
continue
}
if filePath == "" {
filePath = a
}
}
if filePath != "" {
abs, err := resolvePath(filePath)
if err != nil {
return fmt.Sprintf("[error] wc: %v", err)
}
data, err := os.ReadFile(abs)
if err != nil {
return fmt.Sprintf("[error] wc: %v", err)
}
content = string(data)
} else if stdin != "" {
content = stdin
} else {
return "[error] wc: no input (use file path or pipe from stdin)"
}
content = strings.TrimRight(content, "\n")
lines := len(strings.Split(content, "\n"))
words := len(strings.Fields(content))
chars := len(content)
if len(args) > 0 { if len(args) > 0 {
switch args[0] { switch args[0] {
case "-l": case "-l":
@@ -506,17 +885,40 @@ func FsWc(args []string, stdin string) string {
} }
func FsSort(args []string, stdin string) string { func FsSort(args []string, stdin string) string {
lines := strings.Split(stdin, "\n")
reverse := false reverse := false
numeric := false numeric := false
var filePath string
for _, a := range args { for _, a := range args {
switch a { switch a {
case "-r": case "-r":
reverse = true reverse = true
case "-n": case "-n":
numeric = true numeric = true
default:
if filePath == "" && !strings.HasPrefix(a, "-") {
filePath = a
}
} }
} }
var lines []string
if filePath != "" {
abs, err := resolvePath(filePath)
if err != nil {
return fmt.Sprintf("[error] sort: %v", err)
}
data, err := os.ReadFile(abs)
if err != nil {
return fmt.Sprintf("[error] sort: %v", err)
}
lines = strings.Split(string(data), "\n")
} else if stdin != "" {
lines = strings.Split(stdin, "\n")
} else {
return "[error] sort: no input (use file path or pipe from stdin)"
}
for len(lines) > 0 && lines[len(lines)-1] == "" {
lines = lines[:len(lines)-1]
}
sortFunc := func(i, j int) bool { sortFunc := func(i, j int) bool {
if numeric { if numeric {
ni, _ := strconv.Atoi(lines[i]) ni, _ := strconv.Atoi(lines[i])
@@ -536,37 +938,47 @@ func FsSort(args []string, stdin string) string {
} }
func FsUniq(args []string, stdin string) string { func FsUniq(args []string, stdin string) string {
lines := strings.Split(stdin, "\n")
showCount := false showCount := false
var filePath string
for _, a := range args { for _, a := range args {
if a == "-c" { if a == "-c" {
showCount = true showCount = true
} else if filePath == "" && !strings.HasPrefix(a, "-") {
filePath = a
} }
} }
var lines []string
if filePath != "" {
abs, err := resolvePath(filePath)
if err != nil {
return fmt.Sprintf("[error] uniq: %v", err)
}
data, err := os.ReadFile(abs)
if err != nil {
return fmt.Sprintf("[error] uniq: %v", err)
}
lines = strings.Split(string(data), "\n")
} else if stdin != "" {
lines = strings.Split(stdin, "\n")
} else {
return "[error] uniq: no input (use file path or pipe from stdin)"
}
var result []string var result []string
var prev string seen := make(map[string]bool)
first := true countMap := make(map[string]int)
count := 0
for _, line := range lines { for _, line := range lines {
if first || line != prev { countMap[line]++
if !first && showCount { if !seen[line] {
result = append(result, fmt.Sprintf("%d %s", count, prev)) seen[line] = true
} else if !first { result = append(result, line)
result = append(result, prev)
}
count = 1
prev = line
first = false
} else {
count++
} }
} }
if !first { if showCount {
if showCount { var counted []string
result = append(result, fmt.Sprintf("%d %s", count, prev)) for _, line := range result {
} else { counted = append(counted, fmt.Sprintf("%d %s", countMap[line], line))
result = append(result, prev)
} }
return strings.Join(counted, "\n")
} }
return strings.Join(result, "\n") return strings.Join(result, "\n")
} }
@@ -653,7 +1065,7 @@ func FsSed(args []string, stdin string) string {
return "[error] usage: sed 's/old/new/[g]' [file]" return "[error] usage: sed 's/old/new/[g]' [file]"
} }
// Parse pattern: s/old/new/flags // Parse pattern: s/old/new/flags
parts := strings.Split(pattern[1:], "/") parts := strings.Split(pattern[2:], "/")
if len(parts) < 2 { if len(parts) < 2 {
return "[error] invalid sed pattern. Use: s/old/new/[g]" return "[error] invalid sed pattern. Use: s/old/new/[g]"
} }

423
tools/fs_test.go Normal file
View File

@@ -0,0 +1,423 @@
package tools
import (
"os"
"path/filepath"
"strings"
"testing"
"gf-lt/config"
)
func init() {
cfg = &config.Config{}
cwd, _ := os.Getwd()
if strings.HasSuffix(cwd, "/tools") || strings.HasSuffix(cwd, "\\tools") {
cwd = filepath.Dir(cwd)
}
cfg.FilePickerDir = cwd
}
func TestFsLs(t *testing.T) {
tests := []struct {
name string
args []string
stdin string
check func(string) bool
}{
{"no args", []string{}, "", func(r string) bool { return strings.Contains(r, "tools/") }},
{"long format", []string{"-l"}, "", func(r string) bool { return strings.Contains(r, "f ") }},
{"all files", []string{"-a"}, "", func(r string) bool { return strings.Contains(r, ".") || strings.Contains(r, "..") }},
{"combine flags", []string{"-la"}, "", func(r string) bool { return strings.Contains(r, "f ") && strings.Contains(r, ".") }},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := FsLs(tt.args, tt.stdin)
if !tt.check(result) {
t.Errorf("check failed for %q, got %q", tt.name, result)
}
})
}
}
func TestFsCat(t *testing.T) {
tmpFile := filepath.Join(cfg.FilePickerDir, "test_cat.txt")
content := "hello\nworld\n"
os.WriteFile(tmpFile, []byte(content), 0644)
defer os.Remove(tmpFile)
tests := []struct {
name string
args []string
stdin string
want string
}{
{"file path", []string{tmpFile}, "", "hello\nworld\n"},
{"stdin fallback", []string{}, "stdin content", "stdin content"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := FsCat(tt.args, tt.stdin)
if result != tt.want && !strings.Contains(result, tt.want) {
t.Errorf("expected %q, got %q", tt.want, result)
}
})
}
}
func TestFsHead(t *testing.T) {
tmpFile := filepath.Join(cfg.FilePickerDir, "test_head.txt")
content := "line1\nline2\nline3\nline4\nline5\n"
os.WriteFile(tmpFile, []byte(content), 0644)
defer os.Remove(tmpFile)
tests := []struct {
name string
args []string
stdin string
want string
}{
{"default from stdin", []string{}, "line1\nline2\nline3", "line1\nline2\nline3"},
{"n from stdin", []string{"-n", "2"}, "line1\nline2\nline3", "line1\nline2"},
{"numeric n", []string{"-2"}, "line1\nline2\nline3", "line1\nline2"},
{"file path", []string{tmpFile}, "", "line1\nline2\nline3\nline4\nline5"},
{"file with n", []string{"-n", "2", tmpFile}, "", "line1\nline2"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := FsHead(tt.args, tt.stdin)
if result != tt.want && !strings.Contains(result, tt.want) {
t.Errorf("expected %q, got %q", tt.want, result)
}
})
}
}
func TestFsTail(t *testing.T) {
tmpFile := filepath.Join(cfg.FilePickerDir, "test_tail.txt")
content := "line1\nline2\nline3\nline4\nline5\n"
os.WriteFile(tmpFile, []byte(content), 0644)
defer os.Remove(tmpFile)
tests := []struct {
name string
args []string
stdin string
want string
}{
{"default from stdin", []string{}, "line1\nline2\nline3", "line1\nline2\nline3"},
{"n from stdin", []string{"-n", "2"}, "line1\nline2\nline3", "line2\nline3"},
{"file path", []string{tmpFile}, "", "line1\nline2\nline3\nline4\nline5"},
{"file with n", []string{"-n", "3", tmpFile}, "", "line3\nline4\nline5"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := FsTail(tt.args, tt.stdin)
if result != tt.want && !strings.Contains(result, tt.want) {
t.Errorf("expected %q, got %q", tt.want, result)
}
})
}
}
func TestFsWc(t *testing.T) {
tmpFile := filepath.Join(cfg.FilePickerDir, "test_wc.txt")
content := "one two three\nfour five\nsix\n"
os.WriteFile(tmpFile, []byte(content), 0644)
defer os.Remove(tmpFile)
tests := []struct {
name string
args []string
stdin string
want string
}{
{"default", []string{}, "one two", "1 lines, 2 words, 7 chars"},
{"lines", []string{"-l"}, "line1\nline2\nline3", "3"},
{"words", []string{"-w"}, "one two three", "3"},
{"chars", []string{"-c"}, "abc", "3"},
{"file lines", []string{"-l", tmpFile}, "", "3"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := FsWc(tt.args, tt.stdin)
if !strings.Contains(result, tt.want) {
t.Errorf("expected %q in output, got %q", tt.want, result)
}
})
}
}
func TestFsSort(t *testing.T) {
tests := []struct {
name string
args []string
stdin string
want string
}{
{"basic", []string{}, "c\na\nb\n", "a\nb\nc"},
{"reverse", []string{"-r"}, "a\nb\nc", "c\nb\na"},
{"numeric", []string{"-n"}, "10\n2\n1\n", "1\n2\n10"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := FsSort(tt.args, tt.stdin)
if result != tt.want {
t.Errorf("expected %q, got %q", tt.want, result)
}
})
}
}
func TestFsUniq(t *testing.T) {
tests := []struct {
name string
args []string
stdin string
want string
}{
{"basic", []string{}, "a\nb\na\nc", "a\nb\nc"},
{"count", []string{"-c"}, "a\na\nb", "2 a\n1 b"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := FsUniq(tt.args, tt.stdin)
if result != tt.want && !strings.Contains(result, tt.want) {
t.Errorf("expected %q, got %q", tt.want, result)
}
})
}
}
func TestFsGrep(t *testing.T) {
tests := []struct {
name string
args []string
stdin string
want string
}{
{"basic", []string{"world"}, "hello\nworld\ntest", "world"},
{"ignore case", []string{"-i", "WORLD"}, "hello\nworld\ntest", "world"},
{"invert", []string{"-v", "world"}, "hello\nworld\ntest", "hello\ntest"},
{"count", []string{"-c", "o"}, "hello\no world\no foo", "3"},
{"no match", []string{"xyz"}, "hello\nworld", ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := FsGrep(tt.args, tt.stdin)
if tt.want != "" && !strings.Contains(result, tt.want) {
t.Errorf("expected %q, got %q", tt.want, result)
}
})
}
}
func TestFsEcho(t *testing.T) {
tests := []struct {
name string
args []string
stdin string
want string
}{
{"single", []string{"hello"}, "", "hello\n"},
{"multiple", []string{"hello", "world"}, "", "hello world\n"},
{"with stdin", []string{}, "stdin", "stdin"},
{"empty", []string{}, "", ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := FsEcho(tt.args, tt.stdin)
if result != tt.want {
t.Errorf("expected %q, got %q", tt.want, result)
}
})
}
}
func TestFsPwd(t *testing.T) {
result := FsPwd(nil, "")
if !strings.Contains(result, "gf-lt") {
t.Errorf("expected gf-lt in path, got %q", result)
}
}
func TestFsTime(t *testing.T) {
result := FsTime(nil, "")
if len(result) < 10 {
t.Errorf("expected time output, got %q", result)
}
}
func TestFsStat(t *testing.T) {
tmpFile := filepath.Join(cfg.FilePickerDir, "test_stat.txt")
os.WriteFile(tmpFile, []byte("content"), 0644)
defer os.Remove(tmpFile)
result := FsStat([]string{tmpFile}, "")
if !strings.Contains(result, "test_stat.txt") {
t.Errorf("expected filename in output, got %q", result)
}
}
func TestFsMkdir(t *testing.T) {
testDir := filepath.Join(cfg.FilePickerDir, "test_mkdir_xyz")
defer os.RemoveAll(testDir)
result := FsMkdir([]string{testDir}, "")
if _, err := os.Stat(testDir); err != nil {
t.Errorf("directory not created: %v, result: %q", err, result)
}
}
func TestFsCp(t *testing.T) {
src := filepath.Join(cfg.FilePickerDir, "test_cp_src.txt")
dst := filepath.Join(cfg.FilePickerDir, "test_cp_dst.txt")
os.WriteFile(src, []byte("test"), 0644)
defer os.Remove(src)
defer os.Remove(dst)
result := FsCp([]string{src, dst}, "")
if _, err := os.Stat(dst); err != nil {
t.Errorf("file not copied: %v, result: %q", err, result)
}
}
func TestFsMv(t *testing.T) {
src := filepath.Join(cfg.FilePickerDir, "test_mv_src.txt")
dst := filepath.Join(cfg.FilePickerDir, "test_mv_dst.txt")
os.WriteFile(src, []byte("test"), 0644)
defer os.Remove(src)
defer os.Remove(dst)
result := FsMv([]string{src, dst}, "")
if _, err := os.Stat(dst); err != nil {
t.Errorf("file not moved: %v, result: %q", err, result)
}
if _, err := os.Stat(src); err == nil {
t.Errorf("source file still exists")
}
}
func TestFsRm(t *testing.T) {
tmpFile := filepath.Join(cfg.FilePickerDir, "test_rm_xyz.txt")
os.WriteFile(tmpFile, []byte("test"), 0644)
result := FsRm([]string{tmpFile}, "")
if _, err := os.Stat(tmpFile); err == nil {
t.Errorf("file not removed, result: %q", result)
}
}
func TestFsSed(t *testing.T) {
tests := []struct {
name string
args []string
stdin string
want string
}{
{"replace", []string{"s/hello/bye/"}, "hello world", "bye world"},
{"global", []string{"s/o/X/g"}, "hello world", "hellX wXrld"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := FsSed(tt.args, tt.stdin)
if result != tt.want && !strings.Contains(result, tt.want) {
t.Errorf("expected %q, got %q", tt.want, result)
}
})
}
}
func TestPiping(t *testing.T) {
tmpFile := filepath.Join(cfg.FilePickerDir, "test_pipe.txt")
os.WriteFile(tmpFile, []byte("line3\nline1\nline2"), 0644)
defer os.Remove(tmpFile)
tests := []struct {
name string
cmd string
check func(string) bool
}{
{"ls | head -3", "ls | head -3", func(r string) bool { return r != "" }},
{"sort file", "sort " + tmpFile, func(r string) bool { return strings.Contains(r, "line1") }},
{"grep file", "grep line1 " + tmpFile, func(r string) bool { return r == "line1" }},
{"wc file", "wc -l " + tmpFile, func(r string) bool { return r == "3" }},
{"head file", "head -2 " + tmpFile, func(r string) bool { return strings.Contains(r, "line3") }},
{"tail file", "tail -2 " + tmpFile, func(r string) bool { return strings.Contains(r, "line2") }},
{"echo | head", "echo a b c | head -2", func(r string) bool { return strings.Contains(r, "a") }},
{"echo | wc -l", "echo a b c | wc -l", func(r string) bool { return r == "1" }},
{"echo | sort", "echo c a b | sort", func(r string) bool { return strings.Contains(r, "a") }},
{"echo | grep", "echo hello world | grep hello", func(r string) bool { return strings.Contains(r, "hello") }},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := ExecChain(tt.cmd)
if !tt.check(result) {
t.Errorf("check failed for %q, got %q", tt.name, result)
}
})
}
}
func TestChaining(t *testing.T) {
tests := []struct {
name string
cmd string
check func(string) bool
}{
{"ls && echo ok", "ls && echo ok", func(r string) bool { return strings.Contains(r, "ok") }},
{"ls || echo not run", "ls || echo fallback", func(r string) bool { return !strings.Contains(r, "fallback") }},
{"false || echo run", "cd /nonexistent123 || echo fallback", func(r string) bool { return strings.Contains(r, "fallback") }},
{"echo a ; echo b", "echo a ; echo b", func(r string) bool { return strings.Contains(r, "a") && strings.Contains(r, "b") }},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := ExecChain(tt.cmd)
if !tt.check(result) {
t.Errorf("check failed for %q, got %q", tt.name, result)
}
})
}
}
func TestRedirect(t *testing.T) {
tmpFile := filepath.Join(cfg.FilePickerDir, "test_redirect.txt")
os.Remove(tmpFile)
defer os.Remove(tmpFile)
// Test echo >
result1 := ExecChain("echo hello world > " + tmpFile)
if !strings.Contains(result1, "Wrote") {
t.Errorf("echo > failed: %q", result1)
}
// Test cat
result2 := ExecChain("cat " + tmpFile)
if !strings.Contains(result2, "hello") {
t.Errorf("cat failed: %q", result2)
}
// Test echo >>
result3 := ExecChain("echo more >> " + tmpFile)
if !strings.Contains(result3, "Appended") {
t.Errorf("echo >> failed: %q", result3)
}
// Test cat after append
result4 := ExecChain("cat " + tmpFile)
if !strings.Contains(result4, "hello") || !strings.Contains(result4, "more") {
t.Errorf("cat after append failed: %q", result4)
}
}

View File

@@ -35,7 +35,12 @@ Your current tools:
{ {
"name":"run", "name":"run",
"args": ["command"], "args": ["command"],
"when_to_use": "Main tool for file operations, shell commands, memory, git, and todo. Use run \"help\" for all commands. Examples: run \"ls -la\", run \"help\", run \"mkdir -p foo/bar\", run \"cat file.txt\", run \"view_img image.png\", run \"git status\", run \"memory store foo bar\", run \"todo create task\", run \"grep pattern file\", run \"cd /path\", run \"pwd\", run \"find . -name *.txt\", run \"file image.png\", run \"head file\", run \"tail file\", run \"wc -l file\", run \"sort file\", run \"uniq file\", run \"sed 's/old/new/' file\", run \"echo text\", run \"go build ./...\", run \"time\", run \"stat file\", run \"cp src dst\", run \"mv src dst\", run \"rm file\"" "when_to_use": "Main tool for file operations, shell commands, memory, git, and todo. Use run "help" for all commands. Examples: run "ls -la", run "help", run "mkdir -p foo/bar", run "cat file.txt", run "write file.txt content", run "git status", run "memory store foo bar", run "todo create task", run "grep pattern file", run "cd /path", run "pwd", run "find . -name *.txt", run "file image.png", run "head file", run "tail file", run "wc -l file", run "sort file", run "uniq file", run "sed 's/old/new/' file", run "echo text", run "go build ./...", run "time", run "stat file", run "cp src dst", run "mv src dst", run "rm file"
},
{
"name":"browser",
"args": ["action", "args"],
"when_to_use": "Playwright browser automation. Actions: start, stop, running, go <url>, click <selector>, fill <selector> <text>, text [selector], html [selector], screenshot [path], screenshot_and_view, wait <selector>, drag <x1> <y1> <x2> <y2>. Example: browser start, browser go https://example.com, browser click #submit-button"
}, },
{ {
"name":"view_img", "name":"view_img",
@@ -61,11 +66,6 @@ Your current tools:
"name":"read_url_raw", "name":"read_url_raw",
"args": ["url"], "args": ["url"],
"when_to_use": "get raw content from a webpage" "when_to_use": "get raw content from a webpage"
},
{
"name":"browser_agent",
"args": ["task"],
"when_to_use": "autonomous browser automation for complex multi-step tasks like login, form filling, scraping"
} }
] ]
</tools> </tools>
@@ -206,7 +206,7 @@ func (t *Tools) GetWebAgentClient() *agent.AgentClient {
} }
return "" return ""
} }
t.webAgentClient = agent.NewAgentClient(cfg, logger, getToken) t.webAgentClient = agent.NewAgentClient(t.cfg, t.logger, getToken)
}) })
return t.webAgentClient return t.webAgentClient
} }
@@ -408,7 +408,7 @@ func runCmd(args map[string]string) []byte {
case "browser": case "browser":
// browser <action> [args...] - Playwright browser automation // browser <action> [args...] - Playwright browser automation
return runBrowserCommand(rest, args) return runBrowserCommand(rest, args)
case "mkdir", "ls", "cat", "pwd", "cd", "cp", "mv", "rm", "sed", "grep", "head", "tail", "wc", "sort", "uniq", "echo", "time", "stat", "go", "find", "file": case "mkdir", "ls", "cat", "write", "stat", "pwd", "cd", "cp", "mv", "rm", "sed", "grep", "head", "tail", "wc", "sort", "uniq", "echo", "time", "go", "find", "file":
// File operations and shell commands - use ExecChain which has whitelist // File operations and shell commands - use ExecChain which has whitelist
return executeCommand(args) return executeCommand(args)
case "git": case "git":
@@ -420,6 +420,36 @@ func runCmd(args map[string]string) []byte {
} }
} }
// browserCmd handles top-level browser tool calls
func browserCmd(args map[string]string) []byte {
action, _ := args["action"]
argsStr, _ := args["args"]
// Parse args string into slice (space-separated, respecting quoted strings)
var browserArgs []string
if argsStr != "" {
browserArgs = strings.Fields(argsStr)
}
if action == "" {
return []byte(`usage: browser <action> [args...]
Actions:
start - start browser
stop - stop browser
running - check if running
go <url> - navigate to URL
click <selector> - click element
fill <selector> <text> - fill input
text [selector] - extract text
html [selector] - get HTML
screenshot [path] - take screenshot
screenshot_and_view - take and view screenshot
wait <selector> - wait for element
drag <from> <to> - drag element`)
}
// Prepend action to args for runBrowserCommand
fullArgs := append([]string{action}, browserArgs...)
return runBrowserCommand(fullArgs, args)
}
// runBrowserCommand routes browser subcommands to Playwright handlers // runBrowserCommand routes browser subcommands to Playwright handlers
func runBrowserCommand(args []string, originalArgs map[string]string) []byte { func runBrowserCommand(args []string, originalArgs map[string]string) []byte {
if len(args) == 0 { if len(args) == 0 {
@@ -543,6 +573,33 @@ Actions:
"fromSelector": rest[0], "fromSelector": rest[0],
"toSelector": rest[1], "toSelector": rest[1],
}) })
case "help":
return []byte(`browser <action> [args]
Playwright browser automation.
Actions:
start - start browser
stop - stop browser
running - check if browser is running
go <url> - navigate to URL
click <selector> - click element (use index for multiple: click #btn 1)
fill <sel> <text> - fill input field
text [selector] - extract text (from element or whole page)
html [selector] - get HTML (from element or whole page)
screenshot [path] - take screenshot
screenshot_and_view - take and view screenshot
wait <selector> - wait for element to appear
drag <x1> <y1> <x2> <y2> - drag by coordinates
drag <sel1> <sel2> - drag by selectors (center points)
Examples:
browser start
browser go https://example.com
browser click #submit-button
browser fill #search-input hello
browser text
browser screenshot
browser screenshot_and_view
browser drag 100 200 300 400
browser drag #item1 #container2`)
default: default:
return []byte("unknown browser action: " + action) return []byte("unknown browser action: " + action)
} }
@@ -601,20 +658,6 @@ func getHelp(args []string) string {
window - list available windows window - list available windows
capture <name> - capture a window screenshot capture <name> - capture a window screenshot
capture_and_view <name> - capture and view screenshot capture_and_view <name> - capture and view screenshot
# Browser (requires Playwright)
browser start - start browser
browser stop - stop browser
browser running - check if running
browser go <url> - navigate to URL
browser click <sel> - click element
browser fill <sel> <txt> - fill input
browser text [sel] - extract text
browser html [sel] - get HTML
browser screenshot - take screenshot
browser wait <sel> - wait for element
browser drag <x1> <y1> <x2> <y2> - drag by coordinates
browser drag <sel1> <sel2> - drag by selectors (center points)
# System # System
<any shell command> - run shell command directly <any shell command> - run shell command directly
@@ -752,31 +795,6 @@ Use: run "command" to execute.`
Requires: xdotool and maim Requires: xdotool and maim
Examples: Examples:
run "capture_and_view Firefox"` run "capture_and_view Firefox"`
case "browser":
return `browser <action> [args]
Playwright browser automation.
Requires: Playwright browser server running
Actions:
start - start browser
stop - stop browser
running - check if browser is running
go <url> - navigate to URL
click <selector> - click element (use index for multiple: click #btn 1)
fill <selector> <text> - fill input field
text [selector] - extract text (from element or whole page)
html [selector] - get HTML (from element or whole page)
screenshot [path] - take screenshot
wait <selector> - wait for element to appear
drag <from> <to> - drag element to another element
Examples:
run "browser start"
run "browser go https://example.com"
run "browser click #submit-button"
run "browser fill #search-input hello"
run "browser text"
run "browser screenshot"
run "browser drag 100 200 300 400"
run "browser drag #item1 #container2"`
default: default:
return fmt.Sprintf("No help available for: %s. Use: run \"help\" for all commands.", cmd) return fmt.Sprintf("No help available for: %s. Use: run \"help\" for all commands.", cmd)
} }
@@ -1268,7 +1286,9 @@ var FnMap = map[string]fnSig{
"view_img": viewImgTool, "view_img": viewImgTool,
"help": helpTool, "help": helpTool,
// Unified run command // Unified run command
"run": runCmd, "run": runCmd,
// Browser tool - routes to runBrowserCommand
"browser": browserCmd,
"summarize_chat": summarizeChat, "summarize_chat": summarizeChat,
} }
@@ -1298,467 +1318,13 @@ func summarizeChat(args map[string]string) []byte {
return data return data
} }
// func removePlaywrightToolsFromBaseTools() {
// playwrightToolNames := map[string]bool{
// "pw_start": true,
// "pw_stop": true,
// "pw_is_running": true,
// "pw_navigate": true,
// "pw_click": true,
// "pw_click_at": true,
// "pw_fill": true,
// "pw_extract_text": true,
// "pw_screenshot": true,
// "pw_screenshot_and_view": true,
// "pw_wait_for_selector": true,
// "pw_drag": true,
// }
// var filtered []models.Tool
// for _, tool := range BaseTools {
// if !playwrightToolNames[tool.Function.Name] {
// filtered = append(filtered, tool)
// }
// }
// BaseTools = filtered
// delete(FnMap, "pw_start")
// delete(FnMap, "pw_stop")
// delete(FnMap, "pw_is_running")
// delete(FnMap, "pw_navigate")
// delete(FnMap, "pw_click")
// delete(FnMap, "pw_click_at")
// delete(FnMap, "pw_fill")
// delete(FnMap, "pw_extract_text")
// delete(FnMap, "pw_screenshot")
// delete(FnMap, "pw_screenshot_and_view")
// delete(FnMap, "pw_wait_for_selector")
// delete(FnMap, "pw_drag")
// }
// func (t *Tools) RegisterWindowTools(modelHasVision bool) {
// removeWindowToolsFromBaseTools()
// if t.WindowToolsAvailable {
// FnMap["list_windows"] = listWindows
// FnMap["capture_window"] = captureWindow
// windowTools := []models.Tool{
// {
// Type: "function",
// Function: models.ToolFunc{
// Name: "list_windows",
// Description: "List all visible windows with their IDs and names. Returns a map of window ID to window name.",
// Parameters: models.ToolFuncParams{
// Type: "object",
// Required: []string{},
// Properties: map[string]models.ToolArgProps{},
// },
// },
// },
// {
// Type: "function",
// Function: models.ToolFunc{
// Name: "capture_window",
// Description: "Capture a screenshot of a specific window and save it to /tmp. Requires window parameter (window ID or name substring).",
// Parameters: models.ToolFuncParams{
// Type: "object",
// Required: []string{"window"},
// Properties: map[string]models.ToolArgProps{
// "window": models.ToolArgProps{
// Type: "string",
// Description: "window ID or window name (partial match)",
// },
// },
// },
// },
// },
// }
// if modelHasVision {
// FnMap["capture_window_and_view"] = captureWindowAndView
// windowTools = append(windowTools, models.Tool{
// Type: "function",
// Function: models.ToolFunc{
// Name: "capture_window_and_view",
// Description: "Capture a screenshot of a specific window, save it to /tmp, and return the image for viewing. Requires window parameter (window ID or name substring).",
// Parameters: models.ToolFuncParams{
// Type: "object",
// Required: []string{"window"},
// Properties: map[string]models.ToolArgProps{
// "window": models.ToolArgProps{
// Type: "string",
// Description: "window ID or window name (partial match)",
// },
// },
// },
// },
// })
// }
// BaseTools = append(BaseTools, windowTools...)
// ToolSysMsg += windowToolSysMsg
// }
// }
// for pw agentA // for pw agentA
// var browserAgentSysPrompt = `You are an autonomous browser automation agent. Your goal is to complete the user's task by intelligently using browser automation // var browserAgentSysPrompt = `You are an autonomous browser automation agent. Your goal is to complete the user's task by intelligently using browser automation
// Important: The browser may already be running from a previous task! Always check pw_is_running first before starting a new browser. // Important: The browser may already be running from a previous task! Always check pw_is_running first before starting a new browser.
// Available tools:
// - pw_start: Start browser (only if not already running)
// - pw_stop: Stop browser (only when you're truly done and browser is no longer needed)
// - pw_is_running: Check if browser is running
// - pw_navigate: Go to a URL
// - pw_click: Click an element by CSS selector
// - pw_fill: Type text into an input
// - pw_extract_text: Get text from page/element
// - pw_screenshot: Take a screenshot (returns file path)
// - pw_screenshot_and_view: Take screenshot with image for viewing
// - pw_wait_for_selector: Wait for element to appear
// - pw_drag: Drag mouse from one point to another
// - pw_click_at: Click at X,Y coordinates
// - pw_get_html: Get HTML content
// - pw_get_dom: Get structured DOM tree
// - pw_search_elements: Search for elements by text or selector
// Workflow:
// 1. First, check if browser is already running (pw_is_running)
// 2. Only start browser if not already running (pw_start)
// 3. Navigate to required pages (pw_navigate)
// 4. Interact with elements as needed (click, fill, etc.)
// 5. Extract information or take screenshots as requested
// 6. IMPORTANT: Do NOT stop the browser when done! Leave it running so the user can continue interacting with the page in subsequent requests.
// Always provide clear feedback about what you're doing and what you found.` // Always provide clear feedback about what you're doing and what you found.`
// func (t *Tools) runBrowserAgent(args map[string]string) []byte {
// task, ok := args["task"]
// if !ok || task == "" {
// return []byte(`{"error": "task argument is required"}`)
// }
// client := t.GetWebAgentClient()
// pwAgent := agent.NewPWAgent(client, browserAgentSysPrompt)
// pwAgent.SetTools(agent.GetPWTools())
// return pwAgent.ProcessTask(task)
// }
// func registerPlaywrightTools() {
// removePlaywrightToolsFromBaseTools()
// if cfg != nil && cfg.PlaywrightEnabled {
// FnMap["pw_start"] = pwStart
// FnMap["pw_stop"] = pwStop
// FnMap["pw_is_running"] = pwIsRunning
// FnMap["pw_navigate"] = pwNavigate
// FnMap["pw_click"] = pwClick
// FnMap["pw_click_at"] = pwClickAt
// FnMap["pw_fill"] = pwFill
// FnMap["pw_extract_text"] = pwExtractText
// FnMap["pw_screenshot"] = pwScreenshot
// FnMap["pw_screenshot_and_view"] = pwScreenshotAndView
// FnMap["pw_wait_for_selector"] = pwWaitForSelector
// FnMap["pw_drag"] = pwDrag
// FnMap["pw_get_html"] = pwGetHTML
// FnMap["pw_get_dom"] = pwGetDOM
// FnMap["pw_search_elements"] = pwSearchElements
// playwrightTools := []models.Tool{
// {
// Type: "function",
// Function: models.ToolFunc{
// Name: "pw_start",
// Description: "Start a Playwright browser instance. Call this first before using other pw_ Uses headless mode by default (set PlaywrightHeadless=false in config for GUI).",
// Parameters: models.ToolFuncParams{
// Type: "object",
// Required: []string{},
// Properties: map[string]models.ToolArgProps{},
// },
// },
// },
// {
// Type: "function",
// Function: models.ToolFunc{
// Name: "pw_stop",
// Description: "Stop the Playwright browser instance. Call when done with browser automation.",
// Parameters: models.ToolFuncParams{
// Type: "object",
// Required: []string{},
// Properties: map[string]models.ToolArgProps{},
// },
// },
// },
// {
// Type: "function",
// Function: models.ToolFunc{
// Name: "pw_is_running",
// Description: "Check if Playwright browser is currently running.",
// Parameters: models.ToolFuncParams{
// Type: "object",
// Required: []string{},
// Properties: map[string]models.ToolArgProps{},
// },
// },
// },
// {
// Type: "function",
// Function: models.ToolFunc{
// Name: "pw_navigate",
// Description: "Navigate to a URL in the browser.",
// Parameters: models.ToolFuncParams{
// Type: "object",
// Required: []string{"url"},
// Properties: map[string]models.ToolArgProps{
// "url": models.ToolArgProps{
// Type: "string",
// Description: "URL to navigate to",
// },
// },
// },
// },
// },
// {
// Type: "function",
// Function: models.ToolFunc{
// Name: "pw_click",
// Description: "Click on an element using CSS selector. Use 'index' for multiple matches (default 0).",
// Parameters: models.ToolFuncParams{
// Type: "object",
// Required: []string{"selector"},
// Properties: map[string]models.ToolArgProps{
// "selector": models.ToolArgProps{
// Type: "string",
// Description: "CSS selector for the element to click",
// },
// "index": models.ToolArgProps{
// Type: "string",
// Description: "optional index for multiple matches (default 0)",
// },
// },
// },
// },
// },
// {
// Type: "function",
// Function: models.ToolFunc{
// Name: "pw_fill",
// Description: "Fill an input field with text using CSS selector.",
// Parameters: models.ToolFuncParams{
// Type: "object",
// Required: []string{"selector", "text"},
// Properties: map[string]models.ToolArgProps{
// "selector": models.ToolArgProps{
// Type: "string",
// Description: "CSS selector for the input element",
// },
// "text": models.ToolArgProps{
// Type: "string",
// Description: "text to fill into the input",
// },
// "index": models.ToolArgProps{
// Type: "string",
// Description: "optional index for multiple matches (default 0)",
// },
// },
// },
// },
// },
// {
// Type: "function",
// Function: models.ToolFunc{
// Name: "pw_extract_text",
// Description: "Extract text content from the page or specific elements using CSS selector. Use 'body' for all page text.",
// Parameters: models.ToolFuncParams{
// Type: "object",
// Required: []string{"selector"},
// Properties: map[string]models.ToolArgProps{
// "selector": models.ToolArgProps{
// Type: "string",
// Description: "CSS selector (use 'body' for all page text)",
// },
// },
// },
// },
// },
// {
// Type: "function",
// Function: models.ToolFunc{
// Name: "pw_screenshot",
// Description: "Take a screenshot of the page or a specific element. Returns file path to saved image.",
// Parameters: models.ToolFuncParams{
// Type: "object",
// Required: []string{},
// Properties: map[string]models.ToolArgProps{
// "selector": models.ToolArgProps{
// Type: "string",
// Description: "optional CSS selector for element to screenshot",
// },
// "full_page": models.ToolArgProps{
// Type: "string",
// Description: "optional: 'true' to capture full page (default false)",
// },
// },
// },
// },
// },
// {
// Type: "function",
// Function: models.ToolFunc{
// Name: "pw_screenshot_and_view",
// Description: "Take a screenshot and return the image for viewing. Use when model needs to see the screenshot.",
// Parameters: models.ToolFuncParams{
// Type: "object",
// Required: []string{},
// Properties: map[string]models.ToolArgProps{
// "selector": models.ToolArgProps{
// Type: "string",
// Description: "optional CSS selector for element to screenshot",
// },
// "full_page": models.ToolArgProps{
// Type: "string",
// Description: "optional: 'true' to capture full page (default false)",
// },
// },
// },
// },
// },
// {
// Type: "function",
// Function: models.ToolFunc{
// Name: "pw_wait_for_selector",
// Description: "Wait for an element to appear on the page.",
// Parameters: models.ToolFuncParams{
// Type: "object",
// Required: []string{"selector"},
// Properties: map[string]models.ToolArgProps{
// "selector": models.ToolArgProps{
// Type: "string",
// Description: "CSS selector to wait for",
// },
// "timeout": models.ToolArgProps{
// Type: "string",
// Description: "optional timeout in ms (default 30000)",
// },
// },
// },
// },
// },
// {
// Type: "function",
// Function: models.ToolFunc{
// Name: "pw_drag",
// Description: "Drag the mouse from one point to another.",
// Parameters: models.ToolFuncParams{
// Type: "object",
// Required: []string{"x1", "y1", "x2", "y2"},
// Properties: map[string]models.ToolArgProps{
// "x1": models.ToolArgProps{
// Type: "string",
// Description: "starting X coordinate",
// },
// "y1": models.ToolArgProps{
// Type: "string",
// Description: "starting Y coordinate",
// },
// "x2": models.ToolArgProps{
// Type: "string",
// Description: "ending X coordinate",
// },
// "y2": models.ToolArgProps{
// Type: "string",
// Description: "ending Y coordinate",
// },
// },
// },
// },
// },
// {
// Type: "function",
// Function: models.ToolFunc{
// Name: "pw_get_html",
// Description: "Get the HTML content of the page or a specific element.",
// Parameters: models.ToolFuncParams{
// Type: "object",
// Required: []string{},
// Properties: map[string]models.ToolArgProps{
// "selector": models.ToolArgProps{
// Type: "string",
// Description: "optional CSS selector (default: body)",
// },
// },
// },
// },
// },
// {
// Type: "function",
// Function: models.ToolFunc{
// Name: "pw_get_dom",
// Description: "Get a structured DOM representation of an element with tag, attributes, text, and children.",
// Parameters: models.ToolFuncParams{
// Type: "object",
// Required: []string{},
// Properties: map[string]models.ToolArgProps{
// "selector": models.ToolArgProps{
// Type: "string",
// Description: "optional CSS selector (default: body)",
// },
// },
// },
// },
// },
// {
// Type: "function",
// Function: models.ToolFunc{
// Name: "pw_search_elements",
// Description: "Search for elements by text content or CSS selector. Returns matching elements with their tags, text, and HTML.",
// Parameters: models.ToolFuncParams{
// Type: "object",
// Required: []string{},
// Properties: map[string]models.ToolArgProps{
// "text": models.ToolArgProps{
// Type: "string",
// Description: "text to search for in elements",
// },
// "selector": models.ToolArgProps{
// Type: "string",
// Description: "CSS selector to search for",
// },
// },
// },
// },
// },
// }
// BaseTools = append(BaseTools, playwrightTools...)
// ToolSysMsg += browserToolSysMsg
// agent.RegisterPWTool("pw_start", pwStart)
// agent.RegisterPWTool("pw_stop", pwStop)
// agent.RegisterPWTool("pw_is_running", pwIsRunning)
// agent.RegisterPWTool("pw_navigate", pwNavigate)
// agent.RegisterPWTool("pw_click", pwClick)
// agent.RegisterPWTool("pw_click_at", pwClickAt)
// agent.RegisterPWTool("pw_fill", pwFill)
// agent.RegisterPWTool("pw_extract_text", pwExtractText)
// agent.RegisterPWTool("pw_screenshot", pwScreenshot)
// agent.RegisterPWTool("pw_screenshot_and_view", pwScreenshotAndView)
// agent.RegisterPWTool("pw_wait_for_selector", pwWaitForSelector)
// agent.RegisterPWTool("pw_drag", pwDrag)
// agent.RegisterPWTool("pw_get_html", pwGetHTML)
// agent.RegisterPWTool("pw_get_dom", pwGetDOM)
// agent.RegisterPWTool("pw_search_elements", pwSearchElements)
// browserAgentTool := []models.Tool{
// {
// Type: "function",
// Function: models.ToolFunc{
// Name: "browser_agent",
// Description: "Autonomous browser automation agent. Use for complex multi-step browser tasks like 'go to website, login, and take screenshot'. The agent will plan and execute steps automatically using browser ",
// Parameters: models.ToolFuncParams{
// Type: "object",
// Required: []string{"task"},
// Properties: map[string]models.ToolArgProps{
// "task": {Type: "string", Description: "The task to accomplish, e.g., 'go to github.com and take a screenshot of the homepage'"},
// },
// },
// },
// },
// }
// BaseTools = append(BaseTools, browserAgentTool...)
// FnMap["browser_agent"] = tooler.runBrowserAgent
// }
// }
func CallToolWithAgent(name string, args map[string]string) ([]byte, bool) { func CallToolWithAgent(name string, args map[string]string) ([]byte, bool) {
f, ok := FnMap[name] f, ok := FnMap[name]
if !ok { if !ok {
@@ -1911,4 +1477,26 @@ var BaseTools = []models.Tool{
}, },
}, },
}, },
// browser - Playwright browser automation
models.Tool{
Type: "function",
Function: models.ToolFunc{
Name: "browser",
Description: "Playwright browser automation. Actions: start (launch browser), stop (close browser), running (check if browser is running), go <url> (navigate), click <selector> [index] (click element), fill <selector> <text> (type into input), text [selector] (extract text), html [selector] (get HTML), screenshot [path] (take screenshot), screenshot_and_view (take and view), wait <selector> (wait for element), drag <x1> <y1> <x2> <y2> (drag by coords) or drag <sel1> <sel2> (drag by selectors)",
Parameters: models.ToolFuncParams{
Type: "object",
Required: []string{"action"},
Properties: map[string]models.ToolArgProps{
"action": models.ToolArgProps{
Type: "string",
Description: "Browser action: start, stop, running, go, click, fill, text, html, screenshot, screenshot_and_view, wait, drag",
},
"args": models.ToolArgProps{
Type: "string",
Description: "Arguments for the action (e.g., URL for go, selector for click, etc.)",
},
},
},
},
},
} }

316
tools/unix_test.go Normal file
View File

@@ -0,0 +1,316 @@
package tools
import (
"os"
"path/filepath"
"strings"
"testing"
"gf-lt/config"
)
func init() {
cfg = &config.Config{}
cwd, _ := os.Getwd()
if strings.HasSuffix(cwd, "/tools") || strings.HasSuffix(cwd, "\\tools") {
cwd = filepath.Dir(cwd)
}
cfg.FilePickerDir = cwd
}
func TestUnixGlobExpansion(t *testing.T) {
tmpDir := filepath.Join(cfg.FilePickerDir, "test_glob_tmp")
os.MkdirAll(tmpDir, 0755)
defer os.RemoveAll(tmpDir)
os.WriteFile(filepath.Join(tmpDir, "file1.txt"), []byte("content1"), 0644)
os.WriteFile(filepath.Join(tmpDir, "file2.txt"), []byte("content2"), 0644)
os.WriteFile(filepath.Join(tmpDir, "file3.log"), []byte("content3"), 0644)
tests := []struct {
name string
cmd string
wantErr bool
check func(string) bool
}{
{
name: "ls glob txt files",
cmd: "ls " + tmpDir + "/*.txt",
wantErr: false,
check: func(r string) bool { return strings.Contains(r, "file1.txt") && strings.Contains(r, "file2.txt") },
},
{
name: "cat glob txt files",
cmd: "cat " + tmpDir + "/*.txt",
wantErr: false,
check: func(r string) bool { return strings.Contains(r, "content1") && strings.Contains(r, "content2") },
},
{
name: "ls glob no matches",
cmd: "ls " + tmpDir + "/*.nonexistent",
wantErr: false,
check: func(r string) bool { return strings.Contains(r, "no such file") || strings.Contains(r, "(empty") },
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := ExecChain(tt.cmd)
if tt.wantErr && result == "" {
t.Errorf("expected error for %q, got empty", tt.cmd)
}
if !tt.check(result) {
t.Errorf("check failed for %q, got %q", tt.cmd, result)
}
})
}
}
func TestUnixCatMultipleFiles(t *testing.T) {
tmpDir := filepath.Join(cfg.FilePickerDir, "test_cat_multi")
os.MkdirAll(tmpDir, 0755)
defer os.RemoveAll(tmpDir)
os.WriteFile(filepath.Join(tmpDir, "a.txt"), []byte("file a content\n"), 0644)
os.WriteFile(filepath.Join(tmpDir, "b.txt"), []byte("file b content\n"), 0644)
os.WriteFile(filepath.Join(tmpDir, "c.txt"), []byte("file c content\n"), 0644)
tests := []struct {
name string
cmd string
check func(string) bool
}{
{
name: "cat multiple files with paths",
cmd: "cat " + tmpDir + "/a.txt " + tmpDir + "/b.txt",
check: func(r string) bool {
return strings.Contains(r, "file a content") && strings.Contains(r, "file b content")
},
},
{
name: "cat three files",
cmd: "cat " + tmpDir + "/a.txt " + tmpDir + "/b.txt " + tmpDir + "/c.txt",
check: func(r string) bool {
return strings.Contains(r, "file a content") && strings.Contains(r, "file b content") && strings.Contains(r, "file c content")
},
},
{
name: "cat via shell with glob",
cmd: "cat " + tmpDir + "/*.txt",
check: func(r string) bool {
return strings.Contains(r, "file a content") && strings.Contains(r, "file b content")
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := ExecChain(tt.cmd)
if !tt.check(result) {
t.Errorf("check failed for %q, got %q", tt.cmd, result)
}
})
}
}
func TestUnixGrepPatternQuoting(t *testing.T) {
tmpDir := filepath.Join(cfg.FilePickerDir, "test_grep_quote")
os.MkdirAll(tmpDir, 0755)
defer os.RemoveAll(tmpDir)
os.WriteFile(filepath.Join(tmpDir, "animals.txt"), []byte("dog\ncat\nbird\nfish\n"), 0644)
os.WriteFile(filepath.Join(tmpDir, "colors.txt"), []byte("red\nblue\ngreen\n"), 0644)
tests := []struct {
name string
cmd string
check func(string) bool
}{
{
name: "grep with double quotes OR pattern",
cmd: "grep -E \"dog|cat\" " + tmpDir + "/animals.txt",
check: func(r string) bool { return strings.Contains(r, "dog") && strings.Contains(r, "cat") },
},
{
name: "grep with single quotes OR pattern",
cmd: "grep -E 'dog|cat' " + tmpDir + "/animals.txt",
check: func(r string) bool { return strings.Contains(r, "dog") && strings.Contains(r, "cat") },
},
{
name: "grep case insensitive with quotes",
cmd: "grep -iE \"DOG|CAT\" " + tmpDir + "/animals.txt",
check: func(r string) bool { return strings.Contains(r, "dog") && strings.Contains(r, "cat") },
},
{
name: "grep piped from cat",
cmd: "cat " + tmpDir + "/animals.txt | grep -E \"dog|cat\"",
check: func(r string) bool { return strings.Contains(r, "dog") && strings.Contains(r, "cat") },
},
{
name: "grep with complex pattern",
cmd: "grep -E \"red|blue|green\" " + tmpDir + "/colors.txt",
check: func(r string) bool {
return strings.Contains(r, "red") && strings.Contains(r, "blue") && strings.Contains(r, "green")
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := ExecChain(tt.cmd)
if !tt.check(result) {
t.Errorf("check failed for %q, got %q", tt.cmd, result)
}
})
}
}
func TestUnixForLoop(t *testing.T) {
tmpDir := filepath.Join(cfg.FilePickerDir, "test_forloop")
os.MkdirAll(tmpDir, 0755)
defer os.RemoveAll(tmpDir)
os.WriteFile(filepath.Join(tmpDir, "dog.txt"), []byte("I have a dog\n"), 0644)
os.WriteFile(filepath.Join(tmpDir, "cat.txt"), []byte("I have a cat\n"), 0644)
os.WriteFile(filepath.Join(tmpDir, "red.txt"), []byte("red color\n"), 0644)
result := ExecChain("cd " + tmpDir + " && for f in *.txt; do echo \"file: $f\"; done")
if result == "" {
t.Error("empty result from for loop execution")
}
if strings.Contains(result, "file:") {
t.Logf("for loop is supported: %s", result)
} else {
t.Logf("for loops not supported (expected): %s", result)
}
}
func TestUnixGlobWithFileOps(t *testing.T) {
tests := []struct {
name string
cmd string
setup func() string
check func(string) bool
}{
{
name: "rm glob txt files",
cmd: "rm {dir}/*.txt",
setup: func() string {
tmpDir := filepath.Join(cfg.FilePickerDir, "test_rm_glob")
os.MkdirAll(tmpDir, 0755)
os.WriteFile(filepath.Join(tmpDir, "a.txt"), []byte("content"), 0644)
os.WriteFile(filepath.Join(tmpDir, "b.txt"), []byte("content"), 0644)
return tmpDir
},
check: func(r string) bool { return !strings.Contains(r, "[error]") },
},
{
name: "cp glob to dest",
cmd: "cp {dir}/*.txt {dir}/dest/",
setup: func() string {
tmpDir := filepath.Join(cfg.FilePickerDir, "test_cp_glob")
os.MkdirAll(tmpDir, 0755)
os.MkdirAll(filepath.Join(tmpDir, "dest"), 0755)
os.WriteFile(filepath.Join(tmpDir, "a.txt"), []byte("content a"), 0644)
os.WriteFile(filepath.Join(tmpDir, "b.txt"), []byte("content b"), 0644)
return tmpDir
},
check: func(r string) bool { return !strings.Contains(r, "[error]") },
},
{
name: "mv glob to dest",
cmd: "mv {dir}/*.log {dir}/dest/",
setup: func() string {
tmpDir := filepath.Join(cfg.FilePickerDir, "test_mv_glob")
os.MkdirAll(tmpDir, 0755)
os.MkdirAll(filepath.Join(tmpDir, "dest"), 0755)
os.WriteFile(filepath.Join(tmpDir, "c.log"), []byte("content c"), 0644)
return tmpDir
},
check: func(r string) bool { return !strings.Contains(r, "[error]") },
},
{
name: "ls with flags and glob",
cmd: "ls -la {dir}/*.txt",
setup: func() string {
tmpDir := filepath.Join(cfg.FilePickerDir, "test_ls_glob")
os.MkdirAll(tmpDir, 0755)
os.WriteFile(filepath.Join(tmpDir, "a.txt"), []byte("content"), 0644)
os.WriteFile(filepath.Join(tmpDir, "b.txt"), []byte("content"), 0644)
return tmpDir
},
check: func(r string) bool { return strings.Contains(r, "a.txt") || strings.Contains(r, "b.txt") },
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tmpDir := tt.setup()
defer os.RemoveAll(tmpDir)
cmd := strings.ReplaceAll(tt.cmd, "{dir}", tmpDir)
result := ExecChain(cmd)
if !tt.check(result) {
t.Errorf("check failed for %q, got %q", cmd, result)
}
})
}
}
func TestUnixComplexPiping(t *testing.T) {
tmpDir := filepath.Join(cfg.FilePickerDir, "test_pipe_complex")
os.MkdirAll(tmpDir, 0755)
defer os.RemoveAll(tmpDir)
os.WriteFile(filepath.Join(tmpDir, "data.txt"), []byte("apple\nbanana\nAPPLE\ncherry\nbanana\n"), 0644)
tests := []struct {
name string
cmd string
check func(string) bool
}{
{
name: "cat | grep -i | sort",
cmd: "cat " + tmpDir + "/data.txt | grep -i apple | sort",
check: func(r string) bool { return strings.Contains(r, "apple") && !strings.Contains(r, "banana") },
},
{
name: "ls | wc -l",
cmd: "ls " + tmpDir + " | wc -l",
check: func(r string) bool { return strings.TrimSpace(r) == "1" },
},
{
name: "echo > file && cat file",
cmd: "echo 'hello world' > " + tmpDir + "/out.txt && cat " + tmpDir + "/out.txt",
check: func(r string) bool { return strings.Contains(r, "hello world") },
},
{
name: "grep file | head -2",
cmd: "grep a " + tmpDir + "/data.txt | head -2",
check: func(r string) bool { return strings.Contains(r, "apple") || strings.Contains(r, "banana") },
},
{
name: "cat | grep | wc -l",
cmd: "cat " + tmpDir + "/data.txt | grep -i apple | wc -l",
check: func(r string) bool { return strings.TrimSpace(r) == "2" },
},
{
name: "ls | grep txt | head -1",
cmd: "ls " + tmpDir + " | grep txt | head -1",
check: func(r string) bool { return strings.Contains(r, "data.txt") },
},
{
name: "echo | sed replacement",
cmd: "echo 'hello world' | sed 's/world/universe/'",
check: func(r string) bool { return strings.Contains(r, "hello universe") },
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := ExecChain(tt.cmd)
if !tt.check(result) {
t.Errorf("check failed for %q, got %q", tt.cmd, result)
}
})
}
}

11
tui.go
View File

@@ -51,6 +51,7 @@ var (
helpPage = "helpPage" helpPage = "helpPage"
renamePage = "renamePage" renamePage = "renamePage"
RAGPage = "RAGPage" RAGPage = "RAGPage"
dbTablesPage = "dbTables"
propsPage = "propsPage" propsPage = "propsPage"
codeBlockPage = "codeBlockPage" codeBlockPage = "codeBlockPage"
imgPage = "imgPage" imgPage = "imgPage"
@@ -143,6 +144,13 @@ func setShellMode(enabled bool) {
// showToast displays a temporary notification in the bottom-right corner. // showToast displays a temporary notification in the bottom-right corner.
// It auto-hides after 3 seconds. // It auto-hides after 3 seconds.
func showToast(title, message string) { func showToast(title, message string) {
if cfg.UseNotifySend {
notifySend(title, message)
return
}
if cfg.CLIMode {
return
}
sanitize := func(s string, maxLen int) string { sanitize := func(s string, maxLen int) string {
sanitized := strings.Map(func(r rune) rune { sanitized := strings.Map(func(r rune) rune {
if r < 32 && r != '\t' { if r < 32 && r != '\t' {
@@ -230,7 +238,6 @@ func initTUI() {
tview.Styles = colorschemes["default"] tview.Styles = colorschemes["default"]
app = tview.NewApplication() app = tview.NewApplication()
pages = tview.NewPages() pages = tview.NewPages()
outputHandler = &TUIOutputHandler{tv: textView}
shellInput = tview.NewInputField(). shellInput = tview.NewInputField().
SetLabel(fmt.Sprintf("[%s]$ ", cfg.FilePickerDir)). // dynamic prompt SetLabel(fmt.Sprintf("[%s]$ ", cfg.FilePickerDir)). // dynamic prompt
SetFieldWidth(0). SetFieldWidth(0).
@@ -349,6 +356,7 @@ func initTUI() {
// calling it explicitly makes text streaming to look more smooth // calling it explicitly makes text streaming to look more smooth
app.Draw() app.Draw()
}) })
outputHandler = &TUIOutputHandler{tv: textView}
notificationWidget = tview.NewTextView(). notificationWidget = tview.NewTextView().
SetTextAlign(tview.AlignCenter). SetTextAlign(tview.AlignCenter).
SetDynamicColors(true). SetDynamicColors(true).
@@ -1192,5 +1200,4 @@ func initTUI() {
} }
return event return event
}) })
go updateModelLists()
} }