Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4428c06356 | ||
|
|
78918b2949 | ||
|
|
39e099cbe9 | ||
|
|
aea19a6c0d | ||
|
|
6befe7f4bf | ||
|
|
3e51ed2ceb | ||
|
|
ec3ccaae90 | ||
|
|
1504214941 | ||
|
|
50035e667b | ||
|
|
76cc48eb54 | ||
|
|
43d78ff47b | ||
|
|
556eab9d89 | ||
|
|
cdc237c81c | ||
|
|
85c0a0ec62 | ||
|
|
0bc6a09786 | ||
|
|
ef67cbb456 | ||
|
|
267feb0722 | ||
|
|
5413c97b23 | ||
|
|
aff7d73d16 | ||
|
|
9488c5773e | ||
|
|
77506950e4 | ||
|
|
9ff4a465d9 | ||
|
|
11fe89c243 | ||
|
|
4cf8833423 | ||
|
|
7a6d2b8777 | ||
|
|
69a69547ff | ||
|
|
3e4213b5c3 | ||
|
|
451e6f0381 | ||
|
|
47b3d37a97 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -18,3 +18,6 @@ ragimport
|
||||
.env
|
||||
onnx/
|
||||
*.log
|
||||
log.txt
|
||||
dumps/
|
||||
batteries/whisper.cpp
|
||||
|
||||
Submodule batteries/whisper.cpp deleted from a88b93f85f
102
bot.go
102
bot.go
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
2
go.mod
@@ -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
4
go.sum
@@ -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=
|
||||
|
||||
25
helpfuncs.go
25
helpfuncs.go
@@ -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
190
latex.go
Normal 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 terminal‑friendly 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
39
main.go
@@ -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/") {
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
383
tables.go
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
438
tools/chain.go
438
tools/chain.go
@@ -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
|
||||
}
|
||||
|
||||
624
tools/fs.go
624
tools/fs.go
@@ -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", 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", 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
423
tools/fs_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
592
tools/tools.go
592
tools/tools.go
@@ -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
316
tools/unix_test.go
Normal 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
11
tui.go
@@ -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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user