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
onnx/
*.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
lastToolCall *models.FuncCall
lastRespStats *models.ResponseStats
outputHandler OutputHandler
cliPrevOutput string
cliRespDone chan bool
outputHandler OutputHandler
cliPrevOutput string
cliRespDone chan bool
)
type OutputHandler interface {
@@ -65,30 +64,15 @@ type TUIOutputHandler struct {
}
func (h *TUIOutputHandler) Write(p string) {
if h.tv != nil {
fmt.Fprint(h.tv, p)
}
if cfg != nil && cfg.CLIMode {
fmt.Print(p)
cliPrevOutput = p
}
fmt.Fprint(h.tv, p)
}
func (h *TUIOutputHandler) Writef(format string, args ...interface{}) {
s := fmt.Sprintf(format, args...)
if h.tv != nil {
fmt.Fprint(h.tv, s)
}
if cfg != nil && cfg.CLIMode {
fmt.Print(s)
cliPrevOutput = s
}
fmt.Fprintf(h.tv, format, args...)
}
func (h *TUIOutputHandler) ScrollToEnd() {
if h.tv != nil {
h.tv.ScrollToEnd()
}
h.tv.ScrollToEnd()
}
type CLIOutputHandler struct{}
@@ -140,8 +124,6 @@ var (
orModelsData *models.ORModels
)
var thinkBlockRE = regexp.MustCompile(`(?s)<think>.*?</think>`)
// parseKnownToTag extracts known_to list from content using configured tag.
// Returns cleaned content and list of character names.
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
func sendMsgToLLM(body io.Reader) {
choseChunkParser()
// openrouter does not respect stop strings, so we have to cut the message ourselves
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 {
logger.Error("newreq error", "error", err)
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
if resp.StatusCode >= 400 {
// Read the response body to get detailed error information
bodyBytes, err := io.ReadAll(resp.Body)
respBodyBytes, err := io.ReadAll(resp.Body)
if err != nil {
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)
@@ -686,8 +713,9 @@ func sendMsgToLLM(body io.Reader) {
return
}
// 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)
dumpRequestToFile(cfg.CurrentAPI, bodyBytes, chunkParser.GetToken(), resp.StatusCode, detailedError)
showToast("API Error", detailedError)
resp.Body.Close()
streamDone <- true
@@ -1070,7 +1098,7 @@ out:
logger.Warn("failed to update storage", "error", err, "name", activeChatName)
}
// Strip think blocks before parsing for tool calls
respTextNoThink := thinkBlockRE.ReplaceAllString(respText.String(), "")
respTextNoThink := models.ThinkRE.ReplaceAllString(respText.String(), "")
if interruptResp.Load() {
return nil
}
@@ -1414,10 +1442,14 @@ func chatToTextSlice(messages []models.RoleMsg, showSys bool) []string {
// This is a tool call indicator - show collapsed
if toolCollapsed {
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(
fmt.Sprintf(
"%s\n%s\n[yellow::i][tool call: %s (press Ctrl+T to expand)][-:-:-]\n",
icon, messages[i].GetText(), toolName),
"%s\n%s\n[yellow::i][tool call: %s %s (press Ctrl+T to expand)][-:-:-]\n",
icon, messages[i].GetText(), toolName, argsPreview),
"\n\n", "\n")
} else {
// Show full tool call info
@@ -1560,7 +1592,9 @@ func updateModelLists() {
cachedModelColor.Store("green")
updateStatusLine()
UpdateToolCapabilities()
app.Draw()
if !cfg.CLIMode {
app.Draw() // raw?
}
return
}
}
@@ -1736,10 +1770,4 @@ func init() {
// atomic default values
cachedModelColor.Store("orange")
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 "=== Running task ==="
TASK=$(cat "$SCRIPT_DIR/task.txt")
cd /home/grail/projects/plays/goplays/gf-lt
go run . -cli -msg "$TASK"
LMODEL=${LMODEL:-Qwen3.5-9B-Q6_K}
cd ../../
go run . -cli -msg "$TASK" -model "$LMODEL"
echo ""
echo "=== Done ==="
cp "$LOG_FILE" "$SCRIPT_DIR/latest_run.log"
echo "Log file: $LOG_FILE"

View File

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

View File

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

View File

@@ -30,6 +30,7 @@ type Config struct {
DBPATH string `toml:"DBPATH"`
FilePickerDir string `toml:"FilePickerDir"`
FilePickerExts string `toml:"FilePickerExts"`
FSAllowOutOfRoot bool `toml:"FSAllowOutOfRoot"`
ImagePreview bool `toml:"ImagePreview"`
EnableMouse bool `toml:"EnableMouse"`
// embeddings
@@ -76,7 +77,8 @@ type Config struct {
PlaywrightEnabled bool `toml:"PlaywrightEnabled"`
PlaywrightDebug bool `toml:"PlaywrightDebug"` // !headless
// CLI mode
CLIMode bool
CLIMode bool
UseNotifySend bool
}
func LoadConfig(fn string) (*Config, error) {
@@ -88,6 +90,15 @@ func LoadConfig(fn string) (*Config, error) {
if err != nil {
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.APIMap = map[string]string{
config.ChatAPI: config.CompletionAPI,

2
go.mod
View File

@@ -5,7 +5,7 @@ go 1.25.1
require (
github.com/BurntSushi/toml v1.5.0
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/gdamore/tcell/v2 v2.13.2
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/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/searchagent v0.2.0 h1:U2GVjLh/9xZt0xX9OcYk9Q2fMkyzyTiADPUmUisRdtQ=
github.com/GrailFinder/searchagent v0.2.0/go.mod h1:d66tn5+22LI8IGJREUsRBT60P0sFdgQgvQRqyvgItrs=
github.com/GrailFinder/searchagent v0.2.1 h1:c2A8UXEkAMhJgheUzhz4eRH4qvDfRJdZ0PB+Pf6TTAo=
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/go.mod h1:wQHgxUOU3JGuj3oD/QFfxUdlzW6xPHfqyHre6VMY4DQ=
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"
// and updates the textView with the filtered conversation
func refreshChatDisplay() {
if cfg.CLIMode {
return
}
// Determine which character's view to show
viewingAs := cfg.UserRole
if cfg.WriteNextMsgAs != "" {
@@ -178,10 +181,15 @@ func colorText() {
for i, tb := range thinkBlocks {
text = strings.Replace(text, fmt.Sprintf(placeholderThink, i), tb, 1)
}
// text = strings.ReplaceAll(text, `$\rightarrow$`, "->")
text = RenderLatex(text)
textView.SetText(text)
}
func updateStatusLine() {
if cfg.CLIMode {
return // no status line in cli mode
}
status := makeStatusLine()
statusLineWidget.SetText(status)
}
@@ -595,7 +603,6 @@ func executeCommandAndDisplay(cmdText string) {
return
}
}
// Use /bin/sh to support pipes, redirects, etc.
cmd := exec.Command("/bin/sh", "-c", cmdText)
cmd.Dir = workingDir
@@ -1023,3 +1030,19 @@ func GetCardByRole(role string) *models.CharCard {
}
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"
"gf-lt/models"
"gf-lt/pngmeta"
"gf-lt/tools"
"os"
"slices"
"strconv"
@@ -37,12 +38,22 @@ var (
)
func main() {
// parse flags
flag.BoolVar(&cfg.CLIMode, "cli", false, "Run in CLI mode without TUI")
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.BoolVar(&cliContinue, "continue", false, "Continue from last chat (by agent or card)")
flag.StringVar(&cliMsg, "msg", "", "Send message and exit (one-shot mode)")
flag.Parse()
if !cfg.CLIMode {
initTUI()
}
chatBody.Model = cfg.CurrentModel
go updateModelLists()
tools.InitTools(cfg, logger, store)
// tooler = tools.InitTools(cfg, logger, store)
// tooler.RegisterWindowTools(modelHasVision)
if cfg.CLIMode {
runCLIMode()
return
@@ -141,7 +152,8 @@ func printCLIHelp() {
fmt.Println(" /new, /n - Start a new chat (clears conversation)")
fmt.Println(" /card <path>, /c <path> - Load a different syscard")
fmt.Println(" /undo, /u - Delete last message")
fmt.Println(" /history, /ls - List chat history")
fmt.Println(" /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(" /model <name>, /m <name> - Switch model")
fmt.Println(" /api <index>, /a <index> - Switch API link (no index to list)")
@@ -220,6 +232,31 @@ func handleCLICommand(msg string) bool {
activeChatName = name
cfg.AssistantRole = chat.Agent
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":
getModelListForAPI := func(api string) []string {
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) {
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) {
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) 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)
func (d dummyStore) WriteVector(row *models.VectorRow) error { return nil }
func (d dummyStore) SearchClosest(q []float32, limit int) ([]models.VectorRow, error) {

View File

@@ -1,6 +1,7 @@
package storage
import (
"database/sql"
"gf-lt/models"
"log/slog"
@@ -12,6 +13,12 @@ type FullRepo interface {
ChatHistory
Memories
VectorRepo
TableLister
}
type TableLister interface {
ListTables() ([]string, error)
GetTableColumns(table string) ([]TableColumn, error)
}
type ChatHistory interface {
@@ -130,3 +137,24 @@ func NewProviderSQL(dbPath string, logger *slog.Logger) FullRepo {
func (p ProviderSQL) DB() *sqlx.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",
"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."

383
tables.go
View File

@@ -273,7 +273,7 @@ func makeRAGTable(fileList []string, loadedFiles []string) *tview.Flex {
fileTable := tview.NewTable().
SetBorders(true)
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.SetChangedFunc(func() {
app.Draw()
@@ -498,6 +498,14 @@ func makeRAGTable(fileList []string, loadedFiles []string) *tview.Flex {
pages.RemovePage(RAGPage)
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 ragflex
@@ -1189,3 +1197,376 @@ func makeFilePicker() *tview.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/exec"
"path/filepath"
"strconv"
"strings"
)
@@ -14,20 +13,24 @@ import (
type Operator int
const (
OpNone Operator = iota
OpAnd // &&
OpOr // ||
OpSeq // ;
OpPipe // |
OpNone Operator = iota
OpAnd // &&
OpOr // ||
OpSeq // ;
OpPipe // |
OpRedirect // >
OpAppend // >>
)
// Segment is a single command in a chain.
type Segment struct {
Raw string
Op Operator // operator AFTER this segment
Raw string
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).
func ParseChain(input string) []Segment {
var segments []Segment
@@ -36,6 +39,7 @@ func ParseChain(input string) []Segment {
n := len(runes)
for i := 0; i < n; i++ {
ch := runes[i]
// handle quotes
if ch == '\'' || ch == '"' {
quote := ch
@@ -50,6 +54,31 @@ func ParseChain(input string) []Segment {
}
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] == '&' {
segments = append(segments, Segment{
@@ -105,6 +134,74 @@ func ExecChain(command string) string {
if len(segments) == 0 {
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 lastOutput string
var lastErr error
@@ -112,16 +209,13 @@ func ExecChain(command string) string {
for i, seg := range segments {
if i > 0 {
prevOp := segments[i-1].Op
// && semantics: skip if previous failed
if prevOp == OpAnd && lastErr != nil {
continue
}
// || semantics: skip if previous succeeded
if prevOp == OpOr && lastErr == nil {
continue
}
}
// determine stdin for this segment
segStdin := ""
if i == 0 {
segStdin = pipeInput
@@ -129,8 +223,6 @@ func ExecChain(command string) string {
segStdin = lastOutput
}
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 {
continue
}
@@ -138,6 +230,21 @@ func ExecChain(command string) string {
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")
}
@@ -150,19 +257,25 @@ func execSingle(command, stdin string) (string, error) {
name := parts[0]
args := parts[1:]
// 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
}
// Otherwise execute as system command
cmd := exec.Command(name, args...)
if stdin != "" {
cmd.Stdin = strings.NewReader(stdin)
// Check if it's a "not a builtin" error (meaning we should try system command)
if err.Error() == "not a builtin" {
// Execute as system command
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()
if err != nil {
return string(output), err
}
return string(output), nil
// It's a builtin that returned an error
return result, err
}
// 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.
// Returns (result, true) if it was a built-in (even if result is empty).
// Returns ("", false) if it's not a built-in command.
func execBuiltin(name string, args []string, stdin string) (string, bool) {
func execBuiltin(name string, args []string, stdin string) (string, error) {
var result string
switch name {
case "echo":
if stdin != "" {
return stdin, true
}
return strings.Join(args, " "), true
result = FsEcho(args, stdin)
case "time":
return "2006-01-02 15:04:05 MST", true
result = FsTime(args, stdin)
case "cat":
if len(args) == 0 {
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
result = FsCat(args, stdin)
case "pwd":
return cfg.FilePickerDir, true
result = FsPwd(args, stdin)
case "cd":
if len(args) == 0 {
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
result = FsCd(args, stdin)
case "mkdir":
if len(args) == 0 {
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
result = FsMkdir(args, stdin)
case "ls":
dir := "."
for _, a := range args {
if !strings.HasPrefix(a, "-") {
dir = a
break
}
}
abs := dir
if !filepath.IsAbs(dir) {
abs = filepath.Join(cfg.FilePickerDir, dir)
}
entries, err := os.ReadDir(abs)
if err != nil {
return fmt.Sprintf("[error] ls: %v", err), true
}
var out strings.Builder
for _, e := range entries {
info, _ := e.Info()
switch {
case e.IsDir():
fmt.Fprintf(&out, "d %-8s %s/\n", "-", e.Name())
case info != nil:
size := info.Size()
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
result = FsLs(args, stdin)
case "cp":
result = FsCp(args, stdin)
case "mv":
result = FsMv(args, stdin)
case "rm":
result = FsRm(args, stdin)
case "grep":
result = FsGrep(args, stdin)
case "head":
result = FsHead(args, stdin)
case "tail":
result = FsTail(args, stdin)
case "wc":
result = FsWc(args, stdin)
case "sort":
result = FsSort(args, stdin)
case "uniq":
result = FsUniq(args, stdin)
case "sed":
result = FsSed(args, stdin)
case "stat":
result = FsStat(args, stdin)
case "go":
// Allow all go subcommands
if len(args) == 0 {
return "[error] usage: go <subcommand> [options]", true
return "[error] usage: go <subcommand> [options]", nil
}
cmd := exec.Command("go", args...)
cmd.Dir = cfg.FilePickerDir
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Sprintf("[error] go %s: %v\n%s", args[0], err, string(output)), true
return fmt.Sprintf("[error] go %s: %v\n%s", args[0], err, string(output)), nil
}
return string(output), true
case "cp":
if len(args) < 2 {
return "[error] usage: cp <source> <dest>", true
}
src := args[0]
dst := args[1]
if !filepath.IsAbs(src) {
src = filepath.Join(cfg.FilePickerDir, src)
}
if !filepath.IsAbs(dst) {
dst = filepath.Join(cfg.FilePickerDir, dst)
}
data, err := os.ReadFile(src)
if err != nil {
return fmt.Sprintf("[error] cp: %v", err), true
}
err = os.WriteFile(dst, data, 0644)
if err != nil {
return fmt.Sprintf("[error] cp: %v", err), true
}
return "Copied " + src + " to " + dst, true
case "mv":
if len(args) < 2 {
return "[error] usage: mv <source> <dest>", true
}
src := args[0]
dst := args[1]
if !filepath.IsAbs(src) {
src = filepath.Join(cfg.FilePickerDir, src)
}
if !filepath.IsAbs(dst) {
dst = filepath.Join(cfg.FilePickerDir, dst)
}
err := os.Rename(src, dst)
if err != nil {
return fmt.Sprintf("[error] mv: %v", err), true
}
return "Moved " + src + " to " + dst, true
case "rm":
if len(args) == 0 {
return "[error] usage: rm [-r] <file>", true
}
recursive := false
var target string
for _, a := range args {
if a == "-r" || a == "-rf" || a == "-fr" || a == "-recursive" {
recursive = true
} else if target == "" {
target = a
}
}
if target == "" {
return "[error] usage: rm [-r] <file>", true
}
abs := target
if !filepath.IsAbs(target) {
abs = filepath.Join(cfg.FilePickerDir, target)
}
info, err := os.Stat(abs)
if err != nil {
return fmt.Sprintf("[error] rm: %v", err), true
}
if info.IsDir() {
if recursive {
err = os.RemoveAll(abs)
if err != nil {
return fmt.Sprintf("[error] rm: %v", err), true
}
return "Removed " + abs, true
}
return "[error] rm: is a directory (use -r)", true
}
err = os.Remove(abs)
if err != nil {
return fmt.Sprintf("[error] rm: %v", err), true
}
return "Removed " + abs, true
return string(output), nil
default:
return "", errors.New("not a builtin")
}
if strings.HasPrefix(result, "[error]") {
return result, errors.New(result)
}
return result, nil
}
// resolveRedirectPath resolves the target path for a redirect operator
func resolveRedirectPath(path string) (string, error) {
path = strings.TrimSpace(path)
if path == "" {
return "", errors.New("redirect target required")
}
abs, err := resolvePath(path)
if err != nil {
return "", err
}
return abs, nil
}
// writeFile writes content to a file (truncate or append)
func writeFile(path, content string, append bool) error {
flags := os.O_CREATE | os.O_WRONLY
if append {
flags |= os.O_APPEND
} else {
flags |= os.O_TRUNC
}
f, err := os.OpenFile(path, flags, 0644)
if err != nil {
return err
}
defer f.Close()
_, err = f.WriteString(content)
return err
}
// humanSizeChain returns human-readable file size
func humanSizeChain(n int64) string {
switch {
case n >= 1<<20:
return fmt.Sprintf("%.1fMB", float64(n)/float64(1<<20))
case n >= 1<<10:
return fmt.Sprintf("%.1fKB", float64(n)/float64(1<<10))
default:
return fmt.Sprintf("%dB", n)
}
return "", false
}

View File

@@ -9,6 +9,7 @@ import (
"os"
"os/exec"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
@@ -61,16 +62,17 @@ func resolvePath(rel string) (string, error) {
if cfg.FilePickerDir == "" {
return "", errors.New("fs root not set")
}
if filepath.IsAbs(rel) {
isAbs := filepath.IsAbs(rel)
if isAbs {
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 abs, nil
}
abs := filepath.Join(cfg.FilePickerDir, rel)
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 abs, nil
@@ -93,10 +95,77 @@ func IsImageFile(path string) bool {
}
func FsLs(args []string, stdin string) string {
showAll := false
longFormat := false
dir := ""
if len(args) > 0 {
dir = args[0]
for _, a := range args {
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)
if err != nil {
return fmt.Sprintf("[error] %v", err)
@@ -106,15 +175,29 @@ func FsLs(args []string, stdin string) string {
return fmt.Sprintf("[error] ls: %v", err)
}
var out strings.Builder
filter := func(name string) bool {
return showAll || !strings.HasPrefix(name, ".")
}
for _, e := range entries {
name := e.Name()
if !filter(name) {
continue
}
info, _ := e.Info()
switch {
case e.IsDir():
fmt.Fprintf(&out, "d %-8s %s/\n", "-", e.Name())
case info != nil:
fmt.Fprintf(&out, "f %-8s %s\n", humanSize(info.Size()), e.Name())
default:
fmt.Fprintf(&out, "f %-8s %s\n", "?", e.Name())
if longFormat {
if e.IsDir() {
fmt.Fprintf(&out, "d %-8s %s/\n", "-", name)
} else if info != nil {
fmt.Fprintf(&out, "f %-8s %s\n", humanSize(info.Size()), name)
} else {
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 {
@@ -125,33 +208,59 @@ func FsLs(args []string, stdin string) string {
func FsCat(args []string, stdin string) string {
b64 := false
var path string
var paths []string
for _, a := range args {
if a == "-b" || a == "--base64" {
b64 = true
} else if path == "" {
path = a
} else if a != "" {
paths = append(paths, a)
}
}
if path == "" {
return "[error] usage: cat <path>"
}
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)
if len(paths) == 0 {
if stdin != "" {
return stdin
}
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 {
@@ -193,11 +302,6 @@ func FsViewImg(args []string, stdin string) string {
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 {
b64 := false
var path string
@@ -297,60 +401,211 @@ func FsRm(args []string, stdin string) string {
if len(args) == 0 {
return "[error] usage: rm <path>"
}
abs, err := resolvePath(args[0])
if err != nil {
return fmt.Sprintf("[error] %v", err)
force := false
var paths []string
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 {
return fmt.Sprintf("[error] rm: %v", err)
if len(paths) == 0 {
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 {
if len(args) < 2 {
return "[error] usage: cp <src> <dst>"
}
srcAbs, err := resolvePath(args[0])
if err != nil {
return fmt.Sprintf("[error] %v", err)
srcPattern := args[0]
dstPath := args[1]
// 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 {
return fmt.Sprintf("[error] %v", err)
// Check for single file copy (no glob and dst doesn't end with / and is not an existing dir)
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 {
return fmt.Sprintf("[error] cp read: %v", err)
// Copy to directory (either glob, or explicit directory)
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 fmt.Sprintf("[error] cp write: %v", err)
}
return fmt.Sprintf("Copied %s → %s (%s)", args[0], args[1], humanSize(int64(len(data))))
return strings.Join(results, ", ")
}
func FsMv(args []string, stdin string) string {
if len(args) < 2 {
return "[error] usage: mv <src> <dst>"
}
srcAbs, err := resolvePath(args[0])
if err != nil {
return fmt.Sprintf("[error] %v", err)
srcPattern := args[0]
dstPath := args[1]
// 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 {
return fmt.Sprintf("[error] %v", err)
// Check for single file move (no glob and dst doesn't end with / and is not an existing dir)
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 {
@@ -394,7 +649,11 @@ func FsEcho(args []string, stdin string) string {
if stdin != "" {
return stdin
}
return strings.Join(args, " ")
result := strings.Join(args, " ")
if result != "" {
result += "\n"
}
return result
}
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 {
if len(args) == 0 {
return "[error] usage: grep [-i] [-v] [-c] <pattern>"
return "[error] usage: grep [-i] [-v] [-c] [-E] <pattern> [file]"
}
ignoreCase := false
invert := false
countOnly := false
useRegex := false
var pattern string
var filePath string
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 {
case "-i":
ignoreCase = true
@@ -417,24 +694,61 @@ func FsGrep(args []string, stdin string) string {
invert = true
case "-c":
countOnly = true
case "-E":
useRegex = true
default:
pattern = a
if pattern == "" {
pattern = a
} else if filePath == "" {
filePath = a
}
}
}
if pattern == "" {
return "[error] pattern required"
}
if ignoreCase {
pattern = strings.ToLower(pattern)
var lines []string
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
for _, line := range lines {
haystack := line
if ignoreCase {
haystack = strings.ToLower(line)
var match bool
if useRegex {
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 {
match = !match
}
@@ -450,6 +764,7 @@ func FsGrep(args []string, stdin string) string {
func FsHead(args []string, stdin string) string {
n := 10
var filePath string
for i, a := range args {
if a == "-n" && i+1 < len(args) {
if parsed, err := strconv.Atoi(args[i+1]); err == nil {
@@ -459,9 +774,26 @@ func FsHead(args []string, stdin string) string {
continue
} else if parsed, err := strconv.Atoi(a); err == nil {
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 {
lines = lines[:n]
}
@@ -470,6 +802,7 @@ func FsHead(args []string, stdin string) string {
func FsTail(args []string, stdin string) string {
n := 10
var filePath string
for i, a := range args {
if a == "-n" && i+1 < len(args) {
if parsed, err := strconv.Atoi(args[i+1]); err == nil {
@@ -479,9 +812,29 @@ func FsTail(args []string, stdin string) string {
continue
} else if parsed, err := strconv.Atoi(a); err == nil {
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 {
lines = lines[len(lines)-n:]
}
@@ -489,9 +842,35 @@ func FsTail(args []string, stdin string) string {
}
func FsWc(args []string, stdin string) string {
lines := len(strings.Split(stdin, "\n"))
words := len(strings.Fields(stdin))
chars := len(stdin)
var content string
var filePath string
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 {
switch args[0] {
case "-l":
@@ -506,17 +885,40 @@ func FsWc(args []string, stdin string) string {
}
func FsSort(args []string, stdin string) string {
lines := strings.Split(stdin, "\n")
reverse := false
numeric := false
var filePath string
for _, a := range args {
switch a {
case "-r":
reverse = true
case "-n":
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 {
if numeric {
ni, _ := strconv.Atoi(lines[i])
@@ -536,37 +938,47 @@ func FsSort(args []string, stdin string) string {
}
func FsUniq(args []string, stdin string) string {
lines := strings.Split(stdin, "\n")
showCount := false
var filePath string
for _, a := range args {
if a == "-c" {
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 prev string
first := true
count := 0
seen := make(map[string]bool)
countMap := make(map[string]int)
for _, line := range lines {
if first || line != prev {
if !first && showCount {
result = append(result, fmt.Sprintf("%d %s", count, prev))
} else if !first {
result = append(result, prev)
}
count = 1
prev = line
first = false
} else {
count++
countMap[line]++
if !seen[line] {
seen[line] = true
result = append(result, line)
}
}
if !first {
if showCount {
result = append(result, fmt.Sprintf("%d %s", count, prev))
} else {
result = append(result, prev)
if showCount {
var counted []string
for _, line := range result {
counted = append(counted, fmt.Sprintf("%d %s", countMap[line], line))
}
return strings.Join(counted, "\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]"
}
// Parse pattern: s/old/new/flags
parts := strings.Split(pattern[1:], "/")
parts := strings.Split(pattern[2:], "/")
if len(parts) < 2 {
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",
"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",
@@ -61,11 +66,6 @@ Your current tools:
"name":"read_url_raw",
"args": ["url"],
"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>
@@ -206,7 +206,7 @@ func (t *Tools) GetWebAgentClient() *agent.AgentClient {
}
return ""
}
t.webAgentClient = agent.NewAgentClient(cfg, logger, getToken)
t.webAgentClient = agent.NewAgentClient(t.cfg, t.logger, getToken)
})
return t.webAgentClient
}
@@ -408,7 +408,7 @@ func runCmd(args map[string]string) []byte {
case "browser":
// browser <action> [args...] - Playwright browser automation
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
return executeCommand(args)
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
func runBrowserCommand(args []string, originalArgs map[string]string) []byte {
if len(args) == 0 {
@@ -543,6 +573,33 @@ Actions:
"fromSelector": rest[0],
"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:
return []byte("unknown browser action: " + action)
}
@@ -601,20 +658,6 @@ func getHelp(args []string) string {
window - list available windows
capture <name> - capture a window 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
<any shell command> - run shell command directly
@@ -752,31 +795,6 @@ Use: run "command" to execute.`
Requires: xdotool and maim
Examples:
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:
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,
"help": helpTool,
// Unified run command
"run": runCmd,
"run": runCmd,
// Browser tool - routes to runBrowserCommand
"browser": browserCmd,
"summarize_chat": summarizeChat,
}
@@ -1298,467 +1318,13 @@ func summarizeChat(args map[string]string) []byte {
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
// 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.
// 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.`
// 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) {
f, ok := FnMap[name]
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"
renamePage = "renamePage"
RAGPage = "RAGPage"
dbTablesPage = "dbTables"
propsPage = "propsPage"
codeBlockPage = "codeBlockPage"
imgPage = "imgPage"
@@ -143,6 +144,13 @@ func setShellMode(enabled bool) {
// showToast displays a temporary notification in the bottom-right corner.
// It auto-hides after 3 seconds.
func showToast(title, message string) {
if cfg.UseNotifySend {
notifySend(title, message)
return
}
if cfg.CLIMode {
return
}
sanitize := func(s string, maxLen int) string {
sanitized := strings.Map(func(r rune) rune {
if r < 32 && r != '\t' {
@@ -230,7 +238,6 @@ func initTUI() {
tview.Styles = colorschemes["default"]
app = tview.NewApplication()
pages = tview.NewPages()
outputHandler = &TUIOutputHandler{tv: textView}
shellInput = tview.NewInputField().
SetLabel(fmt.Sprintf("[%s]$ ", cfg.FilePickerDir)). // dynamic prompt
SetFieldWidth(0).
@@ -349,6 +356,7 @@ func initTUI() {
// calling it explicitly makes text streaming to look more smooth
app.Draw()
})
outputHandler = &TUIOutputHandler{tv: textView}
notificationWidget = tview.NewTextView().
SetTextAlign(tview.AlignCenter).
SetDynamicColors(true).
@@ -1192,5 +1200,4 @@ func initTUI() {
}
return event
})
go updateModelLists()
}