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
|
.env
|
||||||
onnx/
|
onnx/
|
||||||
*.log
|
*.log
|
||||||
|
log.txt
|
||||||
|
dumps/
|
||||||
|
batteries/whisper.cpp
|
||||||
|
|||||||
Submodule batteries/whisper.cpp deleted from a88b93f85f
92
bot.go
92
bot.go
@@ -48,7 +48,6 @@ var (
|
|||||||
chunkParser ChunkParser
|
chunkParser ChunkParser
|
||||||
lastToolCall *models.FuncCall
|
lastToolCall *models.FuncCall
|
||||||
lastRespStats *models.ResponseStats
|
lastRespStats *models.ResponseStats
|
||||||
|
|
||||||
outputHandler OutputHandler
|
outputHandler OutputHandler
|
||||||
cliPrevOutput string
|
cliPrevOutput string
|
||||||
cliRespDone chan bool
|
cliRespDone chan bool
|
||||||
@@ -65,31 +64,16 @@ type TUIOutputHandler struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *TUIOutputHandler) Write(p string) {
|
func (h *TUIOutputHandler) Write(p string) {
|
||||||
if h.tv != nil {
|
|
||||||
fmt.Fprint(h.tv, p)
|
fmt.Fprint(h.tv, p)
|
||||||
}
|
}
|
||||||
if cfg != nil && cfg.CLIMode {
|
|
||||||
fmt.Print(p)
|
|
||||||
cliPrevOutput = p
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *TUIOutputHandler) Writef(format string, args ...interface{}) {
|
func (h *TUIOutputHandler) Writef(format string, args ...interface{}) {
|
||||||
s := fmt.Sprintf(format, args...)
|
fmt.Fprintf(h.tv, format, args...)
|
||||||
if h.tv != nil {
|
|
||||||
fmt.Fprint(h.tv, s)
|
|
||||||
}
|
|
||||||
if cfg != nil && cfg.CLIMode {
|
|
||||||
fmt.Print(s)
|
|
||||||
cliPrevOutput = s
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *TUIOutputHandler) ScrollToEnd() {
|
func (h *TUIOutputHandler) ScrollToEnd() {
|
||||||
if h.tv != nil {
|
|
||||||
h.tv.ScrollToEnd()
|
h.tv.ScrollToEnd()
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
type CLIOutputHandler struct{}
|
type CLIOutputHandler struct{}
|
||||||
|
|
||||||
@@ -140,8 +124,6 @@ var (
|
|||||||
orModelsData *models.ORModels
|
orModelsData *models.ORModels
|
||||||
)
|
)
|
||||||
|
|
||||||
var thinkBlockRE = regexp.MustCompile(`(?s)<think>.*?</think>`)
|
|
||||||
|
|
||||||
// parseKnownToTag extracts known_to list from content using configured tag.
|
// parseKnownToTag extracts known_to list from content using configured tag.
|
||||||
// Returns cleaned content and list of character names.
|
// Returns cleaned content and list of character names.
|
||||||
func parseKnownToTag(content string) []string {
|
func parseKnownToTag(content string) []string {
|
||||||
@@ -649,12 +631,57 @@ func finalizeRespStats(tokenCount int, startTime time.Time) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func dumpRequestToFile(api string, body []byte, token string, statusCode int, respError string) {
|
||||||
|
dumpDir := "dumps"
|
||||||
|
if err := os.MkdirAll(dumpDir, 0755); err != nil {
|
||||||
|
logger.Warn("failed to create dumps directory", "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
timestamp := time.Now().Format("20060102_150405")
|
||||||
|
bodyFilename := fmt.Sprintf("%s/request_%s_%d_body.json", dumpDir, timestamp, statusCode)
|
||||||
|
curlFilename := fmt.Sprintf("%s/request_%s_%d.curl", dumpDir, timestamp, statusCode)
|
||||||
|
|
||||||
|
if err := os.WriteFile(bodyFilename, body, 0644); err != nil {
|
||||||
|
logger.Warn("failed to write request body dump", "error", err, "filename", bodyFilename)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var authPart string
|
||||||
|
if token != "" {
|
||||||
|
authPart = fmt.Sprintf(`-H "Authorization: Bearer %s"`, token)
|
||||||
|
}
|
||||||
|
|
||||||
|
curlCmd := fmt.Sprintf(`curl -X POST "%s" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
%s \
|
||||||
|
--data-binary @%s`,
|
||||||
|
api, authPart, bodyFilename)
|
||||||
|
|
||||||
|
if err := os.WriteFile(curlFilename, []byte(curlCmd), 0644); err != nil {
|
||||||
|
logger.Warn("failed to write request dump", "error", err, "filename", curlFilename)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Info("request dump saved", "curl_file", curlFilename, "body_file", bodyFilename, "status", statusCode)
|
||||||
|
}
|
||||||
|
|
||||||
// sendMsgToLLM expects streaming resp
|
// sendMsgToLLM expects streaming resp
|
||||||
func sendMsgToLLM(body io.Reader) {
|
func sendMsgToLLM(body io.Reader) {
|
||||||
choseChunkParser()
|
choseChunkParser()
|
||||||
// openrouter does not respect stop strings, so we have to cut the message ourselves
|
// openrouter does not respect stop strings, so we have to cut the message ourselves
|
||||||
stopStrings := chatBody.MakeStopSliceExcluding("", listChatRoles())
|
stopStrings := chatBody.MakeStopSliceExcluding("", listChatRoles())
|
||||||
req, err := http.NewRequest("POST", cfg.CurrentAPI, body)
|
|
||||||
|
// Read body content for potential dump on error
|
||||||
|
bodyBytes, err := io.ReadAll(body)
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("failed to read request body", "error", err)
|
||||||
|
showToast("error", "apicall failed:"+err.Error())
|
||||||
|
streamDone <- true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequest("POST", cfg.CurrentAPI, bytes.NewReader(bodyBytes))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Error("newreq error", "error", err)
|
logger.Error("newreq error", "error", err)
|
||||||
showToast("error", "apicall failed:"+err.Error())
|
showToast("error", "apicall failed:"+err.Error())
|
||||||
@@ -676,7 +703,7 @@ func sendMsgToLLM(body io.Reader) {
|
|||||||
// Check if the initial response is an error before starting to stream
|
// Check if the initial response is an error before starting to stream
|
||||||
if resp.StatusCode >= 400 {
|
if resp.StatusCode >= 400 {
|
||||||
// Read the response body to get detailed error information
|
// Read the response body to get detailed error information
|
||||||
bodyBytes, err := io.ReadAll(resp.Body)
|
respBodyBytes, err := io.ReadAll(resp.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Error("failed to read error response body", "error", err, "status_code", resp.StatusCode)
|
logger.Error("failed to read error response body", "error", err, "status_code", resp.StatusCode)
|
||||||
detailedError := fmt.Sprintf("HTTP Status: %d, Failed to read response body: %v", resp.StatusCode, err)
|
detailedError := fmt.Sprintf("HTTP Status: %d, Failed to read response body: %v", resp.StatusCode, err)
|
||||||
@@ -686,8 +713,9 @@ func sendMsgToLLM(body io.Reader) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Parse the error response for detailed information
|
// Parse the error response for detailed information
|
||||||
detailedError := extractDetailedErrorFromBytes(bodyBytes, resp.StatusCode)
|
detailedError := extractDetailedErrorFromBytes(respBodyBytes, resp.StatusCode)
|
||||||
logger.Error("API returned error status", "status_code", resp.StatusCode, "detailed_error", detailedError)
|
logger.Error("API returned error status", "status_code", resp.StatusCode, "detailed_error", detailedError)
|
||||||
|
dumpRequestToFile(cfg.CurrentAPI, bodyBytes, chunkParser.GetToken(), resp.StatusCode, detailedError)
|
||||||
showToast("API Error", detailedError)
|
showToast("API Error", detailedError)
|
||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
streamDone <- true
|
streamDone <- true
|
||||||
@@ -1070,7 +1098,7 @@ out:
|
|||||||
logger.Warn("failed to update storage", "error", err, "name", activeChatName)
|
logger.Warn("failed to update storage", "error", err, "name", activeChatName)
|
||||||
}
|
}
|
||||||
// Strip think blocks before parsing for tool calls
|
// Strip think blocks before parsing for tool calls
|
||||||
respTextNoThink := thinkBlockRE.ReplaceAllString(respText.String(), "")
|
respTextNoThink := models.ThinkRE.ReplaceAllString(respText.String(), "")
|
||||||
if interruptResp.Load() {
|
if interruptResp.Load() {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -1414,10 +1442,14 @@ func chatToTextSlice(messages []models.RoleMsg, showSys bool) []string {
|
|||||||
// This is a tool call indicator - show collapsed
|
// This is a tool call indicator - show collapsed
|
||||||
if toolCollapsed {
|
if toolCollapsed {
|
||||||
toolName := messages[i].ToolCall.Name
|
toolName := messages[i].ToolCall.Name
|
||||||
|
argsPreview := messages[i].ToolCall.Args
|
||||||
|
if len(messages[i].ToolCall.Args) > 30 {
|
||||||
|
argsPreview = messages[i].ToolCall.Args[:30]
|
||||||
|
}
|
||||||
resp[i] = strings.ReplaceAll(
|
resp[i] = strings.ReplaceAll(
|
||||||
fmt.Sprintf(
|
fmt.Sprintf(
|
||||||
"%s\n%s\n[yellow::i][tool call: %s (press Ctrl+T to expand)][-:-:-]\n",
|
"%s\n%s\n[yellow::i][tool call: %s %s (press Ctrl+T to expand)][-:-:-]\n",
|
||||||
icon, messages[i].GetText(), toolName),
|
icon, messages[i].GetText(), toolName, argsPreview),
|
||||||
"\n\n", "\n")
|
"\n\n", "\n")
|
||||||
} else {
|
} else {
|
||||||
// Show full tool call info
|
// Show full tool call info
|
||||||
@@ -1560,7 +1592,9 @@ func updateModelLists() {
|
|||||||
cachedModelColor.Store("green")
|
cachedModelColor.Store("green")
|
||||||
updateStatusLine()
|
updateStatusLine()
|
||||||
UpdateToolCapabilities()
|
UpdateToolCapabilities()
|
||||||
app.Draw()
|
if !cfg.CLIMode {
|
||||||
|
app.Draw() // raw?
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1736,10 +1770,4 @@ func init() {
|
|||||||
// atomic default values
|
// atomic default values
|
||||||
cachedModelColor.Store("orange")
|
cachedModelColor.Store("orange")
|
||||||
go chatWatcher(ctx)
|
go chatWatcher(ctx)
|
||||||
if !cfg.CLIMode {
|
|
||||||
initTUI()
|
|
||||||
}
|
|
||||||
tools.InitTools(cfg, logger, store)
|
|
||||||
// tooler = tools.InitTools(cfg, logger, store)
|
|
||||||
// tooler.RegisterWindowTools(modelHasVision)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,9 +17,11 @@ echo "=== Running setup ==="
|
|||||||
echo ""
|
echo ""
|
||||||
echo "=== Running task ==="
|
echo "=== Running task ==="
|
||||||
TASK=$(cat "$SCRIPT_DIR/task.txt")
|
TASK=$(cat "$SCRIPT_DIR/task.txt")
|
||||||
cd /home/grail/projects/plays/goplays/gf-lt
|
LMODEL=${LMODEL:-Qwen3.5-9B-Q6_K}
|
||||||
go run . -cli -msg "$TASK"
|
cd ../../
|
||||||
|
go run . -cli -msg "$TASK" -model "$LMODEL"
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "=== Done ==="
|
echo "=== Done ==="
|
||||||
|
cp "$LOG_FILE" "$SCRIPT_DIR/latest_run.log"
|
||||||
echo "Log file: $LOG_FILE"
|
echo "Log file: $LOG_FILE"
|
||||||
|
|||||||
@@ -2,8 +2,10 @@
|
|||||||
|
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
|
||||||
mkdir -p /tmp/sort-img
|
mkdir -p /tmp/sort-img
|
||||||
|
|
||||||
cp ../../../assets/ex01.png /tmp/sort-img/file1.png
|
cp "$SCRIPT_DIR/../../assets/ex01.png" /tmp/sort-img/file1.png
|
||||||
cp ../../../assets/helppage.png /tmp/sort-img/file2.png
|
cp "$SCRIPT_DIR/../../assets/helppage.png" /tmp/sort-img/file2.png
|
||||||
cp ../../../assets/yt_thumb.jpg /tmp/sort-img/file3.jpg
|
cp "$SCRIPT_DIR/../../assets/yt_thumb.jpg" /tmp/sort-img/file3.jpg
|
||||||
|
|||||||
@@ -17,9 +17,12 @@ echo "=== Running setup ==="
|
|||||||
echo ""
|
echo ""
|
||||||
echo "=== Running task ==="
|
echo "=== Running task ==="
|
||||||
TASK=$(cat "$SCRIPT_DIR/task.txt")
|
TASK=$(cat "$SCRIPT_DIR/task.txt")
|
||||||
cd /home/grail/projects/plays/goplays/gf-lt
|
# LMODEL=${LMODEL:-gemma-4-31B-it-Q4_K_M}
|
||||||
go run . -cli -msg "$TASK"
|
LMODEL=${LMODEL:-Qwen3.5-9B-Q6_K}
|
||||||
|
cd ../../
|
||||||
|
go run . -cli -msg "$TASK" -model "$LMODEL"
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "=== Done ==="
|
echo "=== Done ==="
|
||||||
|
cp "$LOG_FILE" "$SCRIPT_DIR/latest_run.log"
|
||||||
echo "Log file: $LOG_FILE"
|
echo "Log file: $LOG_FILE"
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ type Config struct {
|
|||||||
DBPATH string `toml:"DBPATH"`
|
DBPATH string `toml:"DBPATH"`
|
||||||
FilePickerDir string `toml:"FilePickerDir"`
|
FilePickerDir string `toml:"FilePickerDir"`
|
||||||
FilePickerExts string `toml:"FilePickerExts"`
|
FilePickerExts string `toml:"FilePickerExts"`
|
||||||
|
FSAllowOutOfRoot bool `toml:"FSAllowOutOfRoot"`
|
||||||
ImagePreview bool `toml:"ImagePreview"`
|
ImagePreview bool `toml:"ImagePreview"`
|
||||||
EnableMouse bool `toml:"EnableMouse"`
|
EnableMouse bool `toml:"EnableMouse"`
|
||||||
// embeddings
|
// embeddings
|
||||||
@@ -77,6 +78,7 @@ type Config struct {
|
|||||||
PlaywrightDebug bool `toml:"PlaywrightDebug"` // !headless
|
PlaywrightDebug bool `toml:"PlaywrightDebug"` // !headless
|
||||||
// CLI mode
|
// CLI mode
|
||||||
CLIMode bool
|
CLIMode bool
|
||||||
|
UseNotifySend bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func LoadConfig(fn string) (*Config, error) {
|
func LoadConfig(fn string) (*Config, error) {
|
||||||
@@ -88,6 +90,15 @@ func LoadConfig(fn string) (*Config, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
// Default FilePickerDir to current working directory if not set
|
||||||
|
if config.FilePickerDir == "" {
|
||||||
|
cwd, err := os.Getwd()
|
||||||
|
if err != nil {
|
||||||
|
config.FilePickerDir = "."
|
||||||
|
} else {
|
||||||
|
config.FilePickerDir = cwd
|
||||||
|
}
|
||||||
|
}
|
||||||
config.CurrentAPI = config.ChatAPI
|
config.CurrentAPI = config.ChatAPI
|
||||||
config.APIMap = map[string]string{
|
config.APIMap = map[string]string{
|
||||||
config.ChatAPI: config.CompletionAPI,
|
config.ChatAPI: config.CompletionAPI,
|
||||||
|
|||||||
2
go.mod
2
go.mod
@@ -5,7 +5,7 @@ go 1.25.1
|
|||||||
require (
|
require (
|
||||||
github.com/BurntSushi/toml v1.5.0
|
github.com/BurntSushi/toml v1.5.0
|
||||||
github.com/GrailFinder/google-translate-tts v0.1.4
|
github.com/GrailFinder/google-translate-tts v0.1.4
|
||||||
github.com/GrailFinder/searchagent v0.2.0
|
github.com/GrailFinder/searchagent v0.2.1
|
||||||
github.com/PuerkitoBio/goquery v1.11.0
|
github.com/PuerkitoBio/goquery v1.11.0
|
||||||
github.com/gdamore/tcell/v2 v2.13.2
|
github.com/gdamore/tcell/v2 v2.13.2
|
||||||
github.com/glebarez/go-sqlite v1.22.0
|
github.com/glebarez/go-sqlite v1.22.0
|
||||||
|
|||||||
4
go.sum
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/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||||
github.com/GrailFinder/google-translate-tts v0.1.4 h1:NJoPZUGfBrmouQMN19MUcNPNUx4tmf4a8OZRME4E4Mg=
|
github.com/GrailFinder/google-translate-tts v0.1.4 h1:NJoPZUGfBrmouQMN19MUcNPNUx4tmf4a8OZRME4E4Mg=
|
||||||
github.com/GrailFinder/google-translate-tts v0.1.4/go.mod h1:YIOLKR7sObazdUCrSex3u9OVBovU55eYgWa25vsQJ18=
|
github.com/GrailFinder/google-translate-tts v0.1.4/go.mod h1:YIOLKR7sObazdUCrSex3u9OVBovU55eYgWa25vsQJ18=
|
||||||
github.com/GrailFinder/searchagent v0.2.0 h1:U2GVjLh/9xZt0xX9OcYk9Q2fMkyzyTiADPUmUisRdtQ=
|
github.com/GrailFinder/searchagent v0.2.1 h1:c2A8UXEkAMhJgheUzhz4eRH4qvDfRJdZ0PB+Pf6TTAo=
|
||||||
github.com/GrailFinder/searchagent v0.2.0/go.mod h1:d66tn5+22LI8IGJREUsRBT60P0sFdgQgvQRqyvgItrs=
|
github.com/GrailFinder/searchagent v0.2.1/go.mod h1:d66tn5+22LI8IGJREUsRBT60P0sFdgQgvQRqyvgItrs=
|
||||||
github.com/PuerkitoBio/goquery v1.11.0 h1:jZ7pwMQXIITcUXNH83LLk+txlaEy6NVOfTuP43xxfqw=
|
github.com/PuerkitoBio/goquery v1.11.0 h1:jZ7pwMQXIITcUXNH83LLk+txlaEy6NVOfTuP43xxfqw=
|
||||||
github.com/PuerkitoBio/goquery v1.11.0/go.mod h1:wQHgxUOU3JGuj3oD/QFfxUdlzW6xPHfqyHre6VMY4DQ=
|
github.com/PuerkitoBio/goquery v1.11.0/go.mod h1:wQHgxUOU3JGuj3oD/QFfxUdlzW6xPHfqyHre6VMY4DQ=
|
||||||
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
|
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
|
||||||
|
|||||||
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"
|
// It filters messages for the character the user is currently "writing as"
|
||||||
// and updates the textView with the filtered conversation
|
// and updates the textView with the filtered conversation
|
||||||
func refreshChatDisplay() {
|
func refreshChatDisplay() {
|
||||||
|
if cfg.CLIMode {
|
||||||
|
return
|
||||||
|
}
|
||||||
// Determine which character's view to show
|
// Determine which character's view to show
|
||||||
viewingAs := cfg.UserRole
|
viewingAs := cfg.UserRole
|
||||||
if cfg.WriteNextMsgAs != "" {
|
if cfg.WriteNextMsgAs != "" {
|
||||||
@@ -178,10 +181,15 @@ func colorText() {
|
|||||||
for i, tb := range thinkBlocks {
|
for i, tb := range thinkBlocks {
|
||||||
text = strings.Replace(text, fmt.Sprintf(placeholderThink, i), tb, 1)
|
text = strings.Replace(text, fmt.Sprintf(placeholderThink, i), tb, 1)
|
||||||
}
|
}
|
||||||
|
// text = strings.ReplaceAll(text, `$\rightarrow$`, "->")
|
||||||
|
text = RenderLatex(text)
|
||||||
textView.SetText(text)
|
textView.SetText(text)
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateStatusLine() {
|
func updateStatusLine() {
|
||||||
|
if cfg.CLIMode {
|
||||||
|
return // no status line in cli mode
|
||||||
|
}
|
||||||
status := makeStatusLine()
|
status := makeStatusLine()
|
||||||
statusLineWidget.SetText(status)
|
statusLineWidget.SetText(status)
|
||||||
}
|
}
|
||||||
@@ -595,7 +603,6 @@ func executeCommandAndDisplay(cmdText string) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use /bin/sh to support pipes, redirects, etc.
|
// Use /bin/sh to support pipes, redirects, etc.
|
||||||
cmd := exec.Command("/bin/sh", "-c", cmdText)
|
cmd := exec.Command("/bin/sh", "-c", cmdText)
|
||||||
cmd.Dir = workingDir
|
cmd.Dir = workingDir
|
||||||
@@ -1023,3 +1030,19 @@ func GetCardByRole(role string) *models.CharCard {
|
|||||||
}
|
}
|
||||||
return sysMap[cardID]
|
return sysMap[cardID]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func notifySend(topic, message string) error {
|
||||||
|
// Sanitize message to remove control characters that notify-send doesn't handle
|
||||||
|
sanitized := strings.Map(func(r rune) rune {
|
||||||
|
if r < 32 && r != '\t' {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}, message)
|
||||||
|
// Truncate if too long
|
||||||
|
if len(sanitized) > 200 {
|
||||||
|
sanitized = sanitized[:197] + "..."
|
||||||
|
}
|
||||||
|
cmd := exec.Command("notify-send", topic, sanitized)
|
||||||
|
return cmd.Run()
|
||||||
|
}
|
||||||
|
|||||||
190
latex.go
Normal file
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"
|
"fmt"
|
||||||
"gf-lt/models"
|
"gf-lt/models"
|
||||||
"gf-lt/pngmeta"
|
"gf-lt/pngmeta"
|
||||||
|
"gf-lt/tools"
|
||||||
"os"
|
"os"
|
||||||
"slices"
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -37,12 +38,22 @@ var (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
// parse flags
|
||||||
flag.BoolVar(&cfg.CLIMode, "cli", false, "Run in CLI mode without TUI")
|
flag.BoolVar(&cfg.CLIMode, "cli", false, "Run in CLI mode without TUI")
|
||||||
flag.BoolVar(&cfg.ToolUse, "tools", true, "run with tools")
|
flag.BoolVar(&cfg.ToolUse, "tools", true, "run with tools")
|
||||||
|
flag.StringVar(&cfg.CurrentModel, "model", "modelname", "name of the model to use")
|
||||||
flag.StringVar(&cliCardPath, "card", "", "Path to syscard JSON file")
|
flag.StringVar(&cliCardPath, "card", "", "Path to syscard JSON file")
|
||||||
flag.BoolVar(&cliContinue, "continue", false, "Continue from last chat (by agent or card)")
|
flag.BoolVar(&cliContinue, "continue", false, "Continue from last chat (by agent or card)")
|
||||||
flag.StringVar(&cliMsg, "msg", "", "Send message and exit (one-shot mode)")
|
flag.StringVar(&cliMsg, "msg", "", "Send message and exit (one-shot mode)")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
if !cfg.CLIMode {
|
||||||
|
initTUI()
|
||||||
|
}
|
||||||
|
chatBody.Model = cfg.CurrentModel
|
||||||
|
go updateModelLists()
|
||||||
|
tools.InitTools(cfg, logger, store)
|
||||||
|
// tooler = tools.InitTools(cfg, logger, store)
|
||||||
|
// tooler.RegisterWindowTools(modelHasVision)
|
||||||
if cfg.CLIMode {
|
if cfg.CLIMode {
|
||||||
runCLIMode()
|
runCLIMode()
|
||||||
return
|
return
|
||||||
@@ -141,7 +152,8 @@ func printCLIHelp() {
|
|||||||
fmt.Println(" /new, /n - Start a new chat (clears conversation)")
|
fmt.Println(" /new, /n - Start a new chat (clears conversation)")
|
||||||
fmt.Println(" /card <path>, /c <path> - Load a different syscard")
|
fmt.Println(" /card <path>, /c <path> - Load a different syscard")
|
||||||
fmt.Println(" /undo, /u - Delete last message")
|
fmt.Println(" /undo, /u - Delete last message")
|
||||||
fmt.Println(" /history, /ls - List chat history")
|
fmt.Println(" /history, /ls - List chat sessions")
|
||||||
|
fmt.Println(" /hs [index] - Show chat history (messages)")
|
||||||
fmt.Println(" /load <name> - Load a specific chat by name")
|
fmt.Println(" /load <name> - Load a specific chat by name")
|
||||||
fmt.Println(" /model <name>, /m <name> - Switch model")
|
fmt.Println(" /model <name>, /m <name> - Switch model")
|
||||||
fmt.Println(" /api <index>, /a <index> - Switch API link (no index to list)")
|
fmt.Println(" /api <index>, /a <index> - Switch API link (no index to list)")
|
||||||
@@ -220,6 +232,31 @@ func handleCLICommand(msg string) bool {
|
|||||||
activeChatName = name
|
activeChatName = name
|
||||||
cfg.AssistantRole = chat.Agent
|
cfg.AssistantRole = chat.Agent
|
||||||
fmt.Printf("Loaded chat: %s\n", name)
|
fmt.Printf("Loaded chat: %s\n", name)
|
||||||
|
case "/hs":
|
||||||
|
if len(chatBody.Messages) == 0 {
|
||||||
|
fmt.Println("No messages in current chat.")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if len(args) == 0 {
|
||||||
|
fmt.Println("Chat history:")
|
||||||
|
for i := range chatBody.Messages {
|
||||||
|
fmt.Printf("%d: %s\n", i, MsgToText(i, &chatBody.Messages[i]))
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
idx, err := strconv.Atoi(args[0])
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Invalid index: %s\n", args[0])
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if idx < 0 {
|
||||||
|
idx = len(chatBody.Messages) + idx
|
||||||
|
}
|
||||||
|
if idx < 0 || idx >= len(chatBody.Messages) {
|
||||||
|
fmt.Printf("Index out of range (0-%d)\n", len(chatBody.Messages)-1)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
fmt.Printf("%d: %s\n", idx, MsgToText(idx, &chatBody.Messages[idx]))
|
||||||
case "/model", "/m":
|
case "/model", "/m":
|
||||||
getModelListForAPI := func(api string) []string {
|
getModelListForAPI := func(api string) []string {
|
||||||
if strings.Contains(api, "api.deepseek.com/") {
|
if strings.Contains(api, "api.deepseek.com/") {
|
||||||
|
|||||||
@@ -126,6 +126,9 @@ func makePropsTable(props map[string]float32) *tview.Table {
|
|||||||
addCheckboxRow("Image Preview (file picker)", cfg.ImagePreview, func(checked bool) {
|
addCheckboxRow("Image Preview (file picker)", cfg.ImagePreview, func(checked bool) {
|
||||||
cfg.ImagePreview = checked
|
cfg.ImagePreview = checked
|
||||||
})
|
})
|
||||||
|
addCheckboxRow("Allow FS out of root", cfg.FSAllowOutOfRoot, func(checked bool) {
|
||||||
|
cfg.FSAllowOutOfRoot = checked
|
||||||
|
})
|
||||||
addCheckboxRow("Auto turn (for cards with many chars)", cfg.AutoTurn, func(checked bool) {
|
addCheckboxRow("Auto turn (for cards with many chars)", cfg.AutoTurn, func(checked bool) {
|
||||||
cfg.AutoTurn = checked
|
cfg.AutoTurn = checked
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -54,6 +54,12 @@ func (d dummyStore) Recall(agent, topic string) (string, error) { return
|
|||||||
func (d dummyStore) RecallTopics(agent string) ([]string, error) { return nil, nil }
|
func (d dummyStore) RecallTopics(agent string) ([]string, error) { return nil, nil }
|
||||||
func (d dummyStore) Forget(agent, topic string) error { return nil }
|
func (d dummyStore) Forget(agent, topic string) error { return nil }
|
||||||
|
|
||||||
|
// TableLister method
|
||||||
|
func (d dummyStore) ListTables() ([]string, error) { return nil, nil }
|
||||||
|
func (d dummyStore) GetTableColumns(table string) ([]storage.TableColumn, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
// VectorRepo methods (not used but required by interface)
|
// VectorRepo methods (not used but required by interface)
|
||||||
func (d dummyStore) WriteVector(row *models.VectorRow) error { return nil }
|
func (d dummyStore) WriteVector(row *models.VectorRow) error { return nil }
|
||||||
func (d dummyStore) SearchClosest(q []float32, limit int) ([]models.VectorRow, error) {
|
func (d dummyStore) SearchClosest(q []float32, limit int) ([]models.VectorRow, error) {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package storage
|
package storage
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"database/sql"
|
||||||
"gf-lt/models"
|
"gf-lt/models"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
|
||||||
@@ -12,6 +13,12 @@ type FullRepo interface {
|
|||||||
ChatHistory
|
ChatHistory
|
||||||
Memories
|
Memories
|
||||||
VectorRepo
|
VectorRepo
|
||||||
|
TableLister
|
||||||
|
}
|
||||||
|
|
||||||
|
type TableLister interface {
|
||||||
|
ListTables() ([]string, error)
|
||||||
|
GetTableColumns(table string) ([]TableColumn, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type ChatHistory interface {
|
type ChatHistory interface {
|
||||||
@@ -130,3 +137,24 @@ func NewProviderSQL(dbPath string, logger *slog.Logger) FullRepo {
|
|||||||
func (p ProviderSQL) DB() *sqlx.DB {
|
func (p ProviderSQL) DB() *sqlx.DB {
|
||||||
return p.db
|
return p.db
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type TableColumn struct {
|
||||||
|
CID int `db:"cid"`
|
||||||
|
Name string `db:"name"`
|
||||||
|
Type string `db:"type"`
|
||||||
|
NotNull bool `db:"notnull"`
|
||||||
|
DFltVal sql.NullString `db:"dflt_value"`
|
||||||
|
PK int `db:"pk"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p ProviderSQL) ListTables() ([]string, error) {
|
||||||
|
resp := []string{}
|
||||||
|
err := p.db.Select(&resp, "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name;")
|
||||||
|
return resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p ProviderSQL) GetTableColumns(table string) ([]TableColumn, error) {
|
||||||
|
resp := []TableColumn{}
|
||||||
|
err := p.db.Select(&resp, "PRAGMA table_info("+table+");")
|
||||||
|
return resp, err
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"sys_prompt": "You are an expert software engineering assistant. Your goal is to help users with coding tasks, debugging, refactoring, and software development.\n\n## Core Principles\n1. **Security First**: Never expose secrets, keys, or credentials. Never commit sensitive data.\n2. **No Git Actions**: You can READ git info (status, log, diff) for context, but NEVER perform git actions (commit, add, push, checkout, reset, rm, etc.). Let the user handle all git operations.\n3. **Explore Before Execute**: Always understand the codebase structure before making changes.\n4. **Follow Conventions**: Match existing code style, patterns, and frameworks used in the project.\n5. **Be Concise**: Minimize output tokens while maintaining quality. Avoid unnecessary explanations.\n6. **Ask First**: When uncertain about intent, ask the user. Don't assume.\n\n## Workflow for Complex Tasks\nFor multi-step tasks, ALWAYS use the todo system to track progress:\n\n1. **Create Todo List**: At the start of complex tasks, use `todo_create` to break down work into actionable items.\n2. **Update Progress**: Mark items as `in_progress` when working on them, and `completed` when done.\n3. **Check Status**: Use `todo_read` to review your progress.\n\nExample workflow:\n- User: \"Add user authentication to this app\"\n- You: Create todos: [\"Analyze existing auth structure\", \"Check frameworks in use\", \"Implement auth middleware\", \"Add login endpoints\", \"Test implementation\"]\n\n## Task Execution Flow\n\n### Phase 1: Exploration (Always First)\n- Use `file_list` to understand directory structure (path defaults to FilePickerDir if not specified)\n- Use `file_read` to examine relevant files (paths are relative to FilePickerDir unless starting with `/`)\n- Use `execute_command` with `grep`/`find` to search for patterns\n- Check README, Makefile, package.json, or similar for build/test commands\n- Identify: frameworks, conventions, testing approach, lint/typecheck commands\n- **Git reads allowed**: You may use `git status`, `git log`, `git diff` for context, but only to inform your work\n- **Path handling**: Relative paths resolve against FilePickerDir; absolute paths (starting with `/`) bypass it\n\n### Phase 2: Planning\n- For complex tasks: create todo items\n- Identify files that need modification\n- Plan your approach following existing patterns\n\n### Phase 3: Implementation\n- Make changes using appropriate file tools\n- Prefer `file_write` for new files, `file_read` then edit for existing files\n- Follow existing code style exactly\n- Use existing libraries and utilities\n\n### Phase 4: Verification\n- Run tests if available (check for test scripts in README/Makefile)\n- Run linting/type checking commands\n- Verify changes work as expected\n\n### Phase 5: Completion\n- Update todos to `completed`\n- Provide concise summary of changes\n- Reference specific file paths and line numbers when relevant\n- **DO NOT commit changes** - inform user what was done so they can review and commit themselves\n\n## Command Execution\n- Use `execute_command` with a single string containing command and arguments (e.g., `go run main.go`, `ls -la`, `cd /tmp`)\n- Use `cd /path` to change the working directory for file operations",
|
"sys_prompt": "You are a software engineering assistant. Your goal is to help user with coding tasks, debugging, refactoring, and software development.\n\n## Core Principles\n1. **Security First**: Never expose secrets, keys, or credentials. Never commit sensitive data.\n2. **No Git Actions**: You can READ git info (status, log, diff) for context, but NEVER perform git actions (commit, add, push, checkout, reset, rm, etc.). Let the user handle all git operations.\n3. **Explore Before Execute**: Always understand the codebase structure before making changes.\n4. **Follow Conventions**: Match existing code style, patterns, and frameworks used in the project.\n5. **Be Concise**: Minimize output tokens while maintaining quality. Avoid unnecessary explanations.\n6. **Ask First**: When uncertain about intent, ask the user. Don't assume.\n\n## Workflow for Complex Tasks\nFor multi-step tasks, ALWAYS use the todo system to track progress:\n\n1. **Create Todo List**: At the start of complex tasks, use `todo create` to break down work into actionable items.\n2. **Update Progress**: Mark items as `in_progress` when working on them, and `completed` when done.\n3. **Check Status**: Use `todo read` to review your progress.\n\nExample workflow:\n- User: \"Add user authentication to this app\"\n- You: Create todos: [\"Analyze existing auth structure\", \"Check frameworks in use\", \"Implement auth middleware\", \"Add login endpoints\", \"Test implementation\"]\n\n## Task Execution Flow\n\n### Phase 1: Exploration (Always First)\n- Use `run \"ls\"` to understand directory structure\n- Use `run \"cat <file>\"` to examine relevant files\n- Use `run \"grep <pattern>\"` or `run \"find . -name *.go\"` to search for patterns\n- Check README, Makefile, go.mod for build/test commands\n- Identify: frameworks, conventions, testing approach, lint/typecheck commands\n- **Git reads allowed**: You may use `run \"git status\"`, `run \"git log\"`, `run \"git diff\"` for context\n- **Path handling**: Relative paths resolve against FilePickerDir; absolute paths (starting with `/`) bypass it\n\n### Phase 2: Planning\n- For complex tasks: create todo items\n- Identify files that need modification\n- Plan your approach following existing patterns\n\n### Phase 3: Implementation\n- Make changes using `run \"write <file> <content>\"` for new files or full rewrites. For small targeted changes, use `run \"sed -i 's/old/new/g' <file>\"`. For larger edits, read the file first and then use write to overwrite the entire file with updated content.\n- Follow existing code style exactly\n- Use existing libraries and utilities\n\n### Phase 4: Verification\n- Run tests if available (check for test commands in README/Makefile)\n- Run linting/type checking commands\n- Verify changes work as expected\n\n### Phase 5: Completion\n- Update todos to `completed`\n- Provide concise summary of changes\n- Reference specific file paths and line numbers when relevant\n- **DO NOT commit changes** - inform user what was done so they can review and commit themselves\n\n## Available Commands\n- `run \"ls [path]\"` - list files in directory\n- `run \"cat <file>\"` - read file content\n- `run \"write <file> <content>\"` - write/overwrite content to file\n- `run \"stat <file>\"` - get file info (size, type, modified)\n- `run \"rm <file>\"` - delete file\n- `run \"cp <src> <dst>\"` - copy file\n- `run \"mv <src> <dst>\"` - move/rename file\n- `run \"mkdir <dir>\"` - create directory\n- `run \"pwd\"` - print working directory\n- `run \"cd <dir>\"` - change directory\n- `run \"sed 's/old/new/' [file]\"` - text replacement (use -i for in-place editing)\n- `run \"grep <pattern> [file]\"` - filter lines\n- `run \"head [n] [file]\"` - show first n lines\n- `run \"tail [n] [file]\"` - show last n lines\n- `run \"wc [-l|-w|-c] [file]\"` - count lines/words/chars\n- `run \"sort [-r] [file]\"` - sort lines\n- `run \"uniq [file]\"` - remove duplicates\n- `run \"echo <text>\"` - echo back input\n- `run \"time\"` - show current time\n- `run \"go <cmd>\"` - go commands (run, build, test, mod, etc.)\n- `run \"git <cmd>\"` - git commands (status, log, diff, show, branch, etc.)\n- `run \"memory store <topic> <data>\"` - save to memory\n- `run \"memory get <topic>\"` - retrieve from memory\n- `run \"memory list\"` - list all topics\n- `run \"memory forget <topic>\"` - delete from memory\n- `run \"todo create <task>\"` - create a todo\n- `run \"todo read\"` - list all todos\n- `run \"todo update <id> <status>\"` - update todo\n- `run \"todo delete <id>\"` - delete a todo\n- `run \"view_img <file>\"` - view an image file\n\nUse: run \"command\" to execute. Supports chaining: cmd1 | cmd2, cmd1 && cmd2",
|
||||||
"role": "CodingAssistant",
|
"role": "CodingAssistant",
|
||||||
"filepath": "sysprompts/coding_assistant.json",
|
"filepath": "sysprompts/coding_assistant.json",
|
||||||
"first_msg": "Hello! I'm your coding assistant. Give me a specific task and I'll get started. For complex work, I'll track progress with todos."
|
"first_msg": "Hello! I'm your coding assistant. Give me a specific task and I'll get started. For complex work, I'll track progress with todos."
|
||||||
|
|||||||
383
tables.go
383
tables.go
@@ -273,7 +273,7 @@ func makeRAGTable(fileList []string, loadedFiles []string) *tview.Flex {
|
|||||||
fileTable := tview.NewTable().
|
fileTable := tview.NewTable().
|
||||||
SetBorders(true)
|
SetBorders(true)
|
||||||
longStatusView := tview.NewTextView()
|
longStatusView := tview.NewTextView()
|
||||||
longStatusView.SetText("press x to exit")
|
longStatusView.SetText("press x to exit | press d to view DB")
|
||||||
longStatusView.SetBorder(true).SetTitle("status")
|
longStatusView.SetBorder(true).SetTitle("status")
|
||||||
longStatusView.SetChangedFunc(func() {
|
longStatusView.SetChangedFunc(func() {
|
||||||
app.Draw()
|
app.Draw()
|
||||||
@@ -498,6 +498,14 @@ func makeRAGTable(fileList []string, loadedFiles []string) *tview.Flex {
|
|||||||
pages.RemovePage(RAGPage)
|
pages.RemovePage(RAGPage)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
if event.Key() == tcell.KeyRune && event.Rune() == 'd' {
|
||||||
|
pages.RemovePage(RAGPage)
|
||||||
|
dbTable := makeDbTable()
|
||||||
|
if dbTable != nil {
|
||||||
|
pages.AddPage(dbTablesPage, dbTable, true, true)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
return event
|
return event
|
||||||
})
|
})
|
||||||
return ragflex
|
return ragflex
|
||||||
@@ -1189,3 +1197,376 @@ func makeFilePicker() *tview.Flex {
|
|||||||
})
|
})
|
||||||
return flex
|
return flex
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func makeDbTable() *tview.Flex {
|
||||||
|
tables, err := store.ListTables()
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("failed to list tables", "error", err)
|
||||||
|
showToast("error", "failed to list tables: "+err.Error())
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if len(tables) == 0 {
|
||||||
|
showToast("info", "no tables found in database")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
tblList := tview.NewList().ShowSecondaryText(false)
|
||||||
|
rowCounts := make(map[string]int)
|
||||||
|
for _, t := range tables {
|
||||||
|
var count int
|
||||||
|
_ = store.DB().Get(&count, "SELECT COUNT(*) FROM "+t)
|
||||||
|
rowCounts[t] = count
|
||||||
|
tblList.AddItem(t, fmt.Sprintf("%d rows", count), 0, nil)
|
||||||
|
}
|
||||||
|
tblList.SetBorder(true).SetTitle("Tables")
|
||||||
|
dataTable := tview.NewTable().SetBorders(true)
|
||||||
|
dataTable.SetBorder(true).SetTitle("Data")
|
||||||
|
flex := tview.NewFlex().
|
||||||
|
AddItem(tblList, 0, 1, true).
|
||||||
|
AddItem(dataTable, 0, 2, false)
|
||||||
|
loadTableData := func(tableName string, tbl *tview.Table) {
|
||||||
|
rows, err := store.DB().Queryx("SELECT * FROM " + tableName + " LIMIT 80")
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("failed to query table", "table", tableName, "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
columnNames, _ := rows.Columns()
|
||||||
|
tbl.Clear()
|
||||||
|
for c, name := range columnNames {
|
||||||
|
tbl.SetCell(0, c,
|
||||||
|
tview.NewTableCell(name).
|
||||||
|
SetTextColor(tcell.ColorYellow).
|
||||||
|
SetAlign(tview.AlignCenter))
|
||||||
|
}
|
||||||
|
r := 1
|
||||||
|
for rows.Next() {
|
||||||
|
row := make(map[string]interface{})
|
||||||
|
if err := rows.MapScan(row); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for c, name := range columnNames {
|
||||||
|
val, ok := row[name]
|
||||||
|
var cellText string
|
||||||
|
var color tcell.Color
|
||||||
|
if !ok || val == nil {
|
||||||
|
cellText = "NULL"
|
||||||
|
color = tcell.ColorDarkGray
|
||||||
|
} else {
|
||||||
|
cellText = fmt.Sprintf("%v", val)
|
||||||
|
if len(cellText) > 30 {
|
||||||
|
cellText = cellText[:30] + "..."
|
||||||
|
}
|
||||||
|
color = tcell.ColorWhite
|
||||||
|
}
|
||||||
|
tbl.SetCell(r, c,
|
||||||
|
tview.NewTableCell(cellText).
|
||||||
|
SetTextColor(color).
|
||||||
|
SetAlign(tview.AlignCenter))
|
||||||
|
}
|
||||||
|
r++
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
tbl.Select(0, 0)
|
||||||
|
}
|
||||||
|
tblList.SetSelectedFunc(func(idx int, mainText, secondaryText string, rune rune) {
|
||||||
|
if idx >= 0 && idx < len(tables) {
|
||||||
|
loadTableData(tables[idx], dataTable)
|
||||||
|
dataTable.SetBorder(true).SetTitle("Data: " + tables[idx])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
tblList.SetChangedFunc(func(idx int, mainText, secondaryText string, rune rune) {
|
||||||
|
if idx >= 0 && idx < len(tables) {
|
||||||
|
loadTableData(tables[idx], dataTable)
|
||||||
|
dataTable.SetBorder(true).SetTitle("Data: " + tables[idx])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
tblList.SetDoneFunc(func() {
|
||||||
|
pages.RemovePage(dbTablesPage)
|
||||||
|
})
|
||||||
|
tblList.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||||
|
if event.Key() == tcell.KeyRune && event.Rune() == 'x' {
|
||||||
|
pages.RemovePage(dbTablesPage)
|
||||||
|
app.SetFocus(textArea)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if event.Key() == tcell.KeyEnter {
|
||||||
|
idx := tblList.GetCurrentItem()
|
||||||
|
if idx >= 0 && idx < len(tables) {
|
||||||
|
showDbContentView(tables[idx])
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return event
|
||||||
|
})
|
||||||
|
if len(tables) > 0 {
|
||||||
|
tblList.SetCurrentItem(0)
|
||||||
|
}
|
||||||
|
return flex
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateColumnsView(tableName string, tbl *tview.Table) {
|
||||||
|
columns, err := store.GetTableColumns(tableName)
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("failed to get table columns", "table", tableName, "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tbl.Clear()
|
||||||
|
cols := 5
|
||||||
|
tbl.SetFixed(1, 0)
|
||||||
|
for c := 0; c < cols; c++ {
|
||||||
|
color := tcell.ColorYellow
|
||||||
|
var headerText string
|
||||||
|
switch c {
|
||||||
|
case 0:
|
||||||
|
headerText = "CID"
|
||||||
|
case 1:
|
||||||
|
headerText = "Name"
|
||||||
|
case 2:
|
||||||
|
headerText = "Type"
|
||||||
|
case 3:
|
||||||
|
headerText = "NotNull"
|
||||||
|
case 4:
|
||||||
|
headerText = "PK"
|
||||||
|
}
|
||||||
|
tbl.SetCell(0, c,
|
||||||
|
tview.NewTableCell(headerText).
|
||||||
|
SetTextColor(color).
|
||||||
|
SetAlign(tview.AlignCenter).
|
||||||
|
SetSelectable(false))
|
||||||
|
}
|
||||||
|
for r, col := range columns {
|
||||||
|
for c := 0; c < cols; c++ {
|
||||||
|
color := tcell.ColorWhite
|
||||||
|
if col.PK > 0 {
|
||||||
|
color = tcell.ColorRed
|
||||||
|
}
|
||||||
|
switch c {
|
||||||
|
case 0:
|
||||||
|
tbl.SetCell(r+1, c,
|
||||||
|
tview.NewTableCell(fmt.Sprintf("%d", col.CID)).
|
||||||
|
SetTextColor(color).
|
||||||
|
SetAlign(tview.AlignCenter).
|
||||||
|
SetSelectable(false))
|
||||||
|
case 1:
|
||||||
|
tbl.SetCell(r+1, c,
|
||||||
|
tview.NewTableCell(col.Name).
|
||||||
|
SetTextColor(color).
|
||||||
|
SetAlign(tview.AlignCenter).
|
||||||
|
SetSelectable(false))
|
||||||
|
case 2:
|
||||||
|
tbl.SetCell(r+1, c,
|
||||||
|
tview.NewTableCell(col.Type).
|
||||||
|
SetTextColor(color).
|
||||||
|
SetAlign(tview.AlignCenter).
|
||||||
|
SetSelectable(false))
|
||||||
|
case 3:
|
||||||
|
notNull := "N"
|
||||||
|
if col.NotNull {
|
||||||
|
notNull = "Y"
|
||||||
|
}
|
||||||
|
tbl.SetCell(r+1, c,
|
||||||
|
tview.NewTableCell(notNull).
|
||||||
|
SetTextColor(color).
|
||||||
|
SetAlign(tview.AlignCenter).
|
||||||
|
SetSelectable(false))
|
||||||
|
case 4:
|
||||||
|
pk := ""
|
||||||
|
if col.PK > 0 {
|
||||||
|
pk = fmt.Sprintf("%d", col.PK)
|
||||||
|
}
|
||||||
|
tbl.SetCell(r+1, c,
|
||||||
|
tview.NewTableCell(pk).
|
||||||
|
SetTextColor(color).
|
||||||
|
SetAlign(tview.AlignCenter).
|
||||||
|
SetSelectable(false))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tbl.Select(0, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func showDbColumnsView(tableName, parentPage string) {
|
||||||
|
longStatusView := tview.NewTextView()
|
||||||
|
longStatusView.SetText("table: " + tableName + " | press x to exit | press Enter to view content").SetBorder(true).SetTitle("status")
|
||||||
|
longStatusView.SetChangedFunc(func() {
|
||||||
|
app.Draw()
|
||||||
|
})
|
||||||
|
flex := tview.NewFlex().SetDirection(tview.FlexRow).
|
||||||
|
AddItem(longStatusView, 0, 10, false).
|
||||||
|
AddItem(tview.NewTable().SetBorders(true), 0, 60, true)
|
||||||
|
columns, err := store.GetTableColumns(tableName)
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("failed to get table columns", "table", tableName, "error", err)
|
||||||
|
showToast("error", "failed to get columns: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tbl := flex.GetItem(1).(*tview.Table)
|
||||||
|
cols := 5 // CID | Name | Type | NotNull | PK
|
||||||
|
tbl.SetFixed(1, 0)
|
||||||
|
for c := 0; c < cols; c++ {
|
||||||
|
color := tcell.ColorYellow
|
||||||
|
var headerText string
|
||||||
|
switch c {
|
||||||
|
case 0:
|
||||||
|
headerText = "CID"
|
||||||
|
case 1:
|
||||||
|
headerText = "Name"
|
||||||
|
case 2:
|
||||||
|
headerText = "Type"
|
||||||
|
case 3:
|
||||||
|
headerText = "NotNull"
|
||||||
|
case 4:
|
||||||
|
headerText = "PK"
|
||||||
|
}
|
||||||
|
tbl.SetCell(0, c,
|
||||||
|
tview.NewTableCell(headerText).
|
||||||
|
SetTextColor(color).
|
||||||
|
SetAlign(tview.AlignCenter).
|
||||||
|
SetSelectable(false))
|
||||||
|
}
|
||||||
|
for r, col := range columns {
|
||||||
|
for c := 0; c < cols; c++ {
|
||||||
|
color := tcell.ColorWhite
|
||||||
|
if col.PK > 0 {
|
||||||
|
color = tcell.ColorRed
|
||||||
|
}
|
||||||
|
switch c {
|
||||||
|
case 0:
|
||||||
|
tbl.SetCell(r+1, c,
|
||||||
|
tview.NewTableCell(fmt.Sprintf("%d", col.CID)).
|
||||||
|
SetTextColor(color).
|
||||||
|
SetAlign(tview.AlignCenter).
|
||||||
|
SetSelectable(false))
|
||||||
|
case 1:
|
||||||
|
tbl.SetCell(r+1, c,
|
||||||
|
tview.NewTableCell(col.Name).
|
||||||
|
SetTextColor(color).
|
||||||
|
SetAlign(tview.AlignCenter).
|
||||||
|
SetSelectable(false))
|
||||||
|
case 2:
|
||||||
|
tbl.SetCell(r+1, c,
|
||||||
|
tview.NewTableCell(col.Type).
|
||||||
|
SetTextColor(color).
|
||||||
|
SetAlign(tview.AlignCenter).
|
||||||
|
SetSelectable(false))
|
||||||
|
case 3:
|
||||||
|
notNull := "N"
|
||||||
|
if col.NotNull {
|
||||||
|
notNull = "Y"
|
||||||
|
}
|
||||||
|
tbl.SetCell(r+1, c,
|
||||||
|
tview.NewTableCell(notNull).
|
||||||
|
SetTextColor(color).
|
||||||
|
SetAlign(tview.AlignCenter).
|
||||||
|
SetSelectable(false))
|
||||||
|
case 4:
|
||||||
|
pk := ""
|
||||||
|
if col.PK > 0 {
|
||||||
|
pk = fmt.Sprintf("%d", col.PK)
|
||||||
|
}
|
||||||
|
tbl.SetCell(r+1, c,
|
||||||
|
tview.NewTableCell(pk).
|
||||||
|
SetTextColor(color).
|
||||||
|
SetAlign(tview.AlignCenter).
|
||||||
|
SetSelectable(false))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
columnsPageName := "dbColumns"
|
||||||
|
pages.AddPage(columnsPageName, flex, true, true)
|
||||||
|
flex.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||||
|
if event.Key() == tcell.KeyRune && event.Rune() == 'x' {
|
||||||
|
pages.RemovePage(columnsPageName)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if event.Key() == tcell.KeyEnter {
|
||||||
|
pages.RemovePage(columnsPageName)
|
||||||
|
showDbContentView(tableName)
|
||||||
|
}
|
||||||
|
return event
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func showDbContentView(tableName string) {
|
||||||
|
batchSize := 80
|
||||||
|
longStatusView := tview.NewTextView()
|
||||||
|
longStatusView.SetText("table: " + tableName + " | press Enter to load more").SetBorder(true).SetTitle("status")
|
||||||
|
longStatusView.SetChangedFunc(func() {
|
||||||
|
app.Draw()
|
||||||
|
})
|
||||||
|
tbl := tview.NewTable().SetBorders(true).SetFixed(1, 0)
|
||||||
|
flex := tview.NewFlex().SetDirection(tview.FlexRow).
|
||||||
|
AddItem(longStatusView, 0, 10, false).
|
||||||
|
AddItem(tbl, 0, 60, true)
|
||||||
|
contentPageName := "db_content_" + tableName
|
||||||
|
offset := 0
|
||||||
|
var rowCount int
|
||||||
|
_ = store.DB().Get(&rowCount, "SELECT COUNT(*) FROM "+tableName)
|
||||||
|
var columnNames []string
|
||||||
|
loadRows := func(off int) {
|
||||||
|
rows, err := store.DB().Queryx("SELECT * FROM " + tableName + " LIMIT " + fmt.Sprintf("%d", batchSize) + " OFFSET " + fmt.Sprintf("%d", off))
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("failed to query table", "table", tableName, "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if off == 0 {
|
||||||
|
columnNames, _ = rows.Columns()
|
||||||
|
for c, name := range columnNames {
|
||||||
|
tbl.SetCell(0, c,
|
||||||
|
tview.NewTableCell(name).
|
||||||
|
SetTextColor(tcell.ColorYellow).
|
||||||
|
SetAlign(tview.AlignCenter).
|
||||||
|
SetSelectable(false))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
r := off
|
||||||
|
for rows.Next() {
|
||||||
|
row := make(map[string]interface{})
|
||||||
|
if err := rows.MapScan(row); err != nil {
|
||||||
|
logger.Error("failed to scan row", "error", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for c, name := range columnNames {
|
||||||
|
val, ok := row[name]
|
||||||
|
if !ok {
|
||||||
|
tbl.SetCell(r+1, c,
|
||||||
|
tview.NewTableCell("NULL").
|
||||||
|
SetTextColor(tcell.ColorDarkGray).
|
||||||
|
SetAlign(tview.AlignCenter).
|
||||||
|
SetSelectable(false))
|
||||||
|
} else {
|
||||||
|
str := fmt.Sprintf("%v", val)
|
||||||
|
if len(str) > 50 {
|
||||||
|
str = str[:50] + "..."
|
||||||
|
}
|
||||||
|
tbl.SetCell(r+1, c,
|
||||||
|
tview.NewTableCell(str).
|
||||||
|
SetTextColor(tcell.ColorWhite).
|
||||||
|
SetAlign(tview.AlignCenter).
|
||||||
|
SetSelectable(false))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
r++
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
loaded := tbl.GetRowCount() - 1
|
||||||
|
if loaded < rowCount {
|
||||||
|
longStatusView.SetText(fmt.Sprintf("table: %s | loaded %d of %d rows | press Enter for more", tableName, loaded, rowCount))
|
||||||
|
} else {
|
||||||
|
longStatusView.SetText(fmt.Sprintf("table: %s | loaded %d rows (all)", tableName, loaded))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loadRows(0)
|
||||||
|
pages.AddPage(contentPageName, flex, true, true)
|
||||||
|
flex.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||||
|
if event.Key() == tcell.KeyRune && event.Rune() == 'x' {
|
||||||
|
pages.RemovePage(contentPageName)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if event.Key() == tcell.KeyEnter {
|
||||||
|
offset += batchSize
|
||||||
|
loadRows(offset)
|
||||||
|
tbl.ScrollToEnd()
|
||||||
|
}
|
||||||
|
return event
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
392
tools/chain.go
392
tools/chain.go
@@ -6,7 +6,6 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -19,15 +18,19 @@ const (
|
|||||||
OpOr // ||
|
OpOr // ||
|
||||||
OpSeq // ;
|
OpSeq // ;
|
||||||
OpPipe // |
|
OpPipe // |
|
||||||
|
OpRedirect // >
|
||||||
|
OpAppend // >>
|
||||||
)
|
)
|
||||||
|
|
||||||
// Segment is a single command in a chain.
|
// Segment is a single command in a chain.
|
||||||
type Segment struct {
|
type Segment struct {
|
||||||
Raw string
|
Raw string
|
||||||
Op Operator // operator AFTER this segment
|
Op Operator // operator AFTER this segment
|
||||||
|
RedirectTo string // file path for > or >>
|
||||||
|
IsAppend bool // true for >>, false for >
|
||||||
}
|
}
|
||||||
|
|
||||||
// ParseChain splits a command string into segments by &&, ;, and |.
|
// ParseChain splits a command string into segments by &&, ;, |, >, and >>.
|
||||||
// Respects quoted strings (single and double quotes).
|
// Respects quoted strings (single and double quotes).
|
||||||
func ParseChain(input string) []Segment {
|
func ParseChain(input string) []Segment {
|
||||||
var segments []Segment
|
var segments []Segment
|
||||||
@@ -36,6 +39,7 @@ func ParseChain(input string) []Segment {
|
|||||||
n := len(runes)
|
n := len(runes)
|
||||||
for i := 0; i < n; i++ {
|
for i := 0; i < n; i++ {
|
||||||
ch := runes[i]
|
ch := runes[i]
|
||||||
|
|
||||||
// handle quotes
|
// handle quotes
|
||||||
if ch == '\'' || ch == '"' {
|
if ch == '\'' || ch == '"' {
|
||||||
quote := ch
|
quote := ch
|
||||||
@@ -50,6 +54,31 @@ func ParseChain(input string) []Segment {
|
|||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
// >>
|
||||||
|
if ch == '>' && i+1 < n && runes[i+1] == '>' {
|
||||||
|
cmd := strings.TrimSpace(current.String())
|
||||||
|
if cmd != "" {
|
||||||
|
segments = append(segments, Segment{
|
||||||
|
Raw: cmd,
|
||||||
|
Op: OpAppend,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
current.Reset()
|
||||||
|
i++ // skip second >
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// >
|
||||||
|
if ch == '>' {
|
||||||
|
cmd := strings.TrimSpace(current.String())
|
||||||
|
if cmd != "" {
|
||||||
|
segments = append(segments, Segment{
|
||||||
|
Raw: cmd,
|
||||||
|
Op: OpRedirect,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
current.Reset()
|
||||||
|
continue
|
||||||
|
}
|
||||||
// &&
|
// &&
|
||||||
if ch == '&' && i+1 < n && runes[i+1] == '&' {
|
if ch == '&' && i+1 < n && runes[i+1] == '&' {
|
||||||
segments = append(segments, Segment{
|
segments = append(segments, Segment{
|
||||||
@@ -105,6 +134,74 @@ func ExecChain(command string) string {
|
|||||||
if len(segments) == 0 {
|
if len(segments) == 0 {
|
||||||
return "[error] empty command"
|
return "[error] empty command"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if we have a redirect
|
||||||
|
var redirectTo string
|
||||||
|
var isAppend bool
|
||||||
|
redirectIdx := -1
|
||||||
|
for i, seg := range segments {
|
||||||
|
if seg.Op == OpRedirect || seg.Op == OpAppend {
|
||||||
|
redirectIdx = i
|
||||||
|
isAppend = seg.Op == OpAppend
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if redirectIdx >= 0 && redirectIdx+1 < len(segments) {
|
||||||
|
targetPath, err := resolveRedirectPath(segments[redirectIdx+1].Raw)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Sprintf("[error] redirect: %v", err)
|
||||||
|
}
|
||||||
|
redirectTo = targetPath
|
||||||
|
redirectCmd := segments[redirectIdx].Raw
|
||||||
|
|
||||||
|
// Check if there's chaining after redirect by looking at the operator of the segment AFTER the target
|
||||||
|
// The segment at redirectIdx+1 is the target file, and its Op tells us what operator follows
|
||||||
|
hasChainingAfterRedirect := false
|
||||||
|
if redirectIdx+1 < len(segments) && segments[redirectIdx+1].Op != OpNone && segments[redirectIdx+1].Op != OpPipe {
|
||||||
|
hasChainingAfterRedirect = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove both the redirect segment and its target
|
||||||
|
segments = append(segments[:redirectIdx], segments[redirectIdx+2:]...)
|
||||||
|
|
||||||
|
// Execute the redirect command
|
||||||
|
var lastOutput string
|
||||||
|
var lastErr error
|
||||||
|
lastOutput, lastErr = execSingle(redirectCmd, "")
|
||||||
|
if lastErr != nil {
|
||||||
|
return fmt.Sprintf("[error] redirect: %v", lastErr)
|
||||||
|
}
|
||||||
|
if err := writeFile(redirectTo, lastOutput, isAppend); err != nil {
|
||||||
|
return fmt.Sprintf("[error] redirect: %v", err)
|
||||||
|
}
|
||||||
|
mode := "Wrote"
|
||||||
|
if isAppend {
|
||||||
|
mode = "Appended"
|
||||||
|
}
|
||||||
|
size := humanSizeChain(int64(len(lastOutput)))
|
||||||
|
redirectResult := fmt.Sprintf("%s %s → %s", mode, size, filepath.Base(redirectTo))
|
||||||
|
|
||||||
|
// If no remaining segments or no chaining, just return the write confirmation
|
||||||
|
if len(segments) == 0 || !hasChainingAfterRedirect {
|
||||||
|
return redirectResult
|
||||||
|
}
|
||||||
|
|
||||||
|
// There are remaining commands after the redirect
|
||||||
|
collected := []string{redirectResult}
|
||||||
|
|
||||||
|
// Execute remaining commands
|
||||||
|
for _, seg := range segments {
|
||||||
|
lastOutput, lastErr = execSingle(seg.Raw, "")
|
||||||
|
if lastOutput != "" {
|
||||||
|
collected = append(collected, lastOutput)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Join(collected, "\n")
|
||||||
|
} else if redirectIdx >= 0 && redirectIdx+1 >= len(segments) {
|
||||||
|
return "[error] redirect: target file required"
|
||||||
|
}
|
||||||
|
|
||||||
var collected []string
|
var collected []string
|
||||||
var lastOutput string
|
var lastOutput string
|
||||||
var lastErr error
|
var lastErr error
|
||||||
@@ -112,16 +209,13 @@ func ExecChain(command string) string {
|
|||||||
for i, seg := range segments {
|
for i, seg := range segments {
|
||||||
if i > 0 {
|
if i > 0 {
|
||||||
prevOp := segments[i-1].Op
|
prevOp := segments[i-1].Op
|
||||||
// && semantics: skip if previous failed
|
|
||||||
if prevOp == OpAnd && lastErr != nil {
|
if prevOp == OpAnd && lastErr != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// || semantics: skip if previous succeeded
|
|
||||||
if prevOp == OpOr && lastErr == nil {
|
if prevOp == OpOr && lastErr == nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// determine stdin for this segment
|
|
||||||
segStdin := ""
|
segStdin := ""
|
||||||
if i == 0 {
|
if i == 0 {
|
||||||
segStdin = pipeInput
|
segStdin = pipeInput
|
||||||
@@ -129,8 +223,6 @@ func ExecChain(command string) string {
|
|||||||
segStdin = lastOutput
|
segStdin = lastOutput
|
||||||
}
|
}
|
||||||
lastOutput, lastErr = execSingle(seg.Raw, segStdin)
|
lastOutput, lastErr = execSingle(seg.Raw, segStdin)
|
||||||
// pipe: output flows to next command's stdin
|
|
||||||
// && or ;: collect output
|
|
||||||
if i < len(segments)-1 && seg.Op == OpPipe {
|
if i < len(segments)-1 && seg.Op == OpPipe {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -138,6 +230,21 @@ func ExecChain(command string) string {
|
|||||||
collected = append(collected, lastOutput)
|
collected = append(collected, lastOutput)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle redirect if present
|
||||||
|
if redirectTo != "" {
|
||||||
|
output := lastOutput
|
||||||
|
if err := writeFile(redirectTo, output, isAppend); err != nil {
|
||||||
|
return fmt.Sprintf("[error] redirect: %v", err)
|
||||||
|
}
|
||||||
|
mode := "Wrote"
|
||||||
|
if isAppend {
|
||||||
|
mode = "Appended"
|
||||||
|
}
|
||||||
|
size := humanSizeChain(int64(len(output)))
|
||||||
|
return fmt.Sprintf("%s %s → %s", mode, size, filepath.Base(redirectTo))
|
||||||
|
}
|
||||||
|
|
||||||
return strings.Join(collected, "\n")
|
return strings.Join(collected, "\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,10 +257,13 @@ func execSingle(command, stdin string) (string, error) {
|
|||||||
name := parts[0]
|
name := parts[0]
|
||||||
args := parts[1:]
|
args := parts[1:]
|
||||||
// Check if it's a built-in Go command
|
// Check if it's a built-in Go command
|
||||||
if result, isBuiltin := execBuiltin(name, args, stdin); isBuiltin {
|
result, err := execBuiltin(name, args, stdin)
|
||||||
|
if err == nil {
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
// Otherwise execute as system command
|
// Check if it's a "not a builtin" error (meaning we should try system command)
|
||||||
|
if err.Error() == "not a builtin" {
|
||||||
|
// Execute as system command
|
||||||
cmd := exec.Command(name, args...)
|
cmd := exec.Command(name, args...)
|
||||||
if stdin != "" {
|
if stdin != "" {
|
||||||
cmd.Stdin = strings.NewReader(stdin)
|
cmd.Stdin = strings.NewReader(stdin)
|
||||||
@@ -164,6 +274,9 @@ func execSingle(command, stdin string) (string, error) {
|
|||||||
}
|
}
|
||||||
return string(output), nil
|
return string(output), nil
|
||||||
}
|
}
|
||||||
|
// It's a builtin that returned an error
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
|
||||||
// tokenize splits a command string by whitespace, respecting quotes.
|
// tokenize splits a command string by whitespace, respecting quotes.
|
||||||
func tokenize(input string) []string {
|
func tokenize(input string) []string {
|
||||||
@@ -201,216 +314,103 @@ func tokenize(input string) []string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// execBuiltin executes a built-in command if it exists.
|
// execBuiltin executes a built-in command if it exists.
|
||||||
// Returns (result, true) if it was a built-in (even if result is empty).
|
func execBuiltin(name string, args []string, stdin string) (string, error) {
|
||||||
// Returns ("", false) if it's not a built-in command.
|
var result string
|
||||||
func execBuiltin(name string, args []string, stdin string) (string, bool) {
|
|
||||||
switch name {
|
switch name {
|
||||||
case "echo":
|
case "echo":
|
||||||
if stdin != "" {
|
result = FsEcho(args, stdin)
|
||||||
return stdin, true
|
|
||||||
}
|
|
||||||
return strings.Join(args, " "), true
|
|
||||||
case "time":
|
case "time":
|
||||||
return "2006-01-02 15:04:05 MST", true
|
result = FsTime(args, stdin)
|
||||||
case "cat":
|
case "cat":
|
||||||
if len(args) == 0 {
|
result = FsCat(args, stdin)
|
||||||
if stdin != "" {
|
|
||||||
return stdin, true
|
|
||||||
}
|
|
||||||
return "", true
|
|
||||||
}
|
|
||||||
path := args[0]
|
|
||||||
abs := path
|
|
||||||
if !filepath.IsAbs(path) {
|
|
||||||
abs = filepath.Join(cfg.FilePickerDir, path)
|
|
||||||
}
|
|
||||||
data, err := os.ReadFile(abs)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Sprintf("[error] cat: %v", err), true
|
|
||||||
}
|
|
||||||
return string(data), true
|
|
||||||
case "pwd":
|
case "pwd":
|
||||||
return cfg.FilePickerDir, true
|
result = FsPwd(args, stdin)
|
||||||
case "cd":
|
case "cd":
|
||||||
if len(args) == 0 {
|
result = FsCd(args, stdin)
|
||||||
return "[error] usage: cd <dir>", true
|
|
||||||
}
|
|
||||||
dir := args[0]
|
|
||||||
// Resolve relative to cfg.FilePickerDir
|
|
||||||
abs := dir
|
|
||||||
if !filepath.IsAbs(dir) {
|
|
||||||
abs = filepath.Join(cfg.FilePickerDir, dir)
|
|
||||||
}
|
|
||||||
abs = filepath.Clean(abs)
|
|
||||||
info, err := os.Stat(abs)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Sprintf("[error] cd: %v", err), true
|
|
||||||
}
|
|
||||||
if !info.IsDir() {
|
|
||||||
return "[error] cd: not a directory: " + dir, true
|
|
||||||
}
|
|
||||||
cfg.FilePickerDir = abs
|
|
||||||
return "Changed directory to: " + cfg.FilePickerDir, true
|
|
||||||
case "mkdir":
|
case "mkdir":
|
||||||
if len(args) == 0 {
|
result = FsMkdir(args, stdin)
|
||||||
return "[error] usage: mkdir [-p] <dir>", true
|
|
||||||
}
|
|
||||||
createParents := false
|
|
||||||
var dirPath string
|
|
||||||
for _, a := range args {
|
|
||||||
if a == "-p" || a == "--parents" {
|
|
||||||
createParents = true
|
|
||||||
} else if dirPath == "" {
|
|
||||||
dirPath = a
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if dirPath == "" {
|
|
||||||
return "[error] usage: mkdir [-p] <dir>", true
|
|
||||||
}
|
|
||||||
abs := dirPath
|
|
||||||
if !filepath.IsAbs(dirPath) {
|
|
||||||
abs = filepath.Join(cfg.FilePickerDir, dirPath)
|
|
||||||
}
|
|
||||||
abs = filepath.Clean(abs)
|
|
||||||
var mkdirFunc func(string, os.FileMode) error
|
|
||||||
if createParents {
|
|
||||||
mkdirFunc = os.MkdirAll
|
|
||||||
} else {
|
|
||||||
mkdirFunc = os.Mkdir
|
|
||||||
}
|
|
||||||
if err := mkdirFunc(abs, 0o755); err != nil {
|
|
||||||
return fmt.Sprintf("[error] mkdir: %v", err), true
|
|
||||||
}
|
|
||||||
if createParents {
|
|
||||||
return "Created " + dirPath + " (with parents)", true
|
|
||||||
}
|
|
||||||
return "Created " + dirPath, true
|
|
||||||
case "ls":
|
case "ls":
|
||||||
dir := "."
|
result = FsLs(args, stdin)
|
||||||
for _, a := range args {
|
case "cp":
|
||||||
if !strings.HasPrefix(a, "-") {
|
result = FsCp(args, stdin)
|
||||||
dir = a
|
case "mv":
|
||||||
break
|
result = FsMv(args, stdin)
|
||||||
}
|
case "rm":
|
||||||
}
|
result = FsRm(args, stdin)
|
||||||
abs := dir
|
case "grep":
|
||||||
if !filepath.IsAbs(dir) {
|
result = FsGrep(args, stdin)
|
||||||
abs = filepath.Join(cfg.FilePickerDir, dir)
|
case "head":
|
||||||
}
|
result = FsHead(args, stdin)
|
||||||
entries, err := os.ReadDir(abs)
|
case "tail":
|
||||||
if err != nil {
|
result = FsTail(args, stdin)
|
||||||
return fmt.Sprintf("[error] ls: %v", err), true
|
case "wc":
|
||||||
}
|
result = FsWc(args, stdin)
|
||||||
var out strings.Builder
|
case "sort":
|
||||||
for _, e := range entries {
|
result = FsSort(args, stdin)
|
||||||
info, _ := e.Info()
|
case "uniq":
|
||||||
switch {
|
result = FsUniq(args, stdin)
|
||||||
case e.IsDir():
|
case "sed":
|
||||||
fmt.Fprintf(&out, "d %-8s %s/\n", "-", e.Name())
|
result = FsSed(args, stdin)
|
||||||
case info != nil:
|
case "stat":
|
||||||
size := info.Size()
|
result = FsStat(args, stdin)
|
||||||
sizeStr := strconv.FormatInt(size, 10)
|
|
||||||
if size > 1024 {
|
|
||||||
sizeStr = fmt.Sprintf("%.1fKB", float64(size)/1024)
|
|
||||||
}
|
|
||||||
fmt.Fprintf(&out, "f %-8s %s\n", sizeStr, e.Name())
|
|
||||||
default:
|
|
||||||
fmt.Fprintf(&out, "f %-8s %s\n", "?", e.Name())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if out.Len() == 0 {
|
|
||||||
return "(empty directory)", true
|
|
||||||
}
|
|
||||||
return strings.TrimRight(out.String(), "\n"), true
|
|
||||||
case "go":
|
case "go":
|
||||||
// Allow all go subcommands
|
|
||||||
if len(args) == 0 {
|
if len(args) == 0 {
|
||||||
return "[error] usage: go <subcommand> [options]", true
|
return "[error] usage: go <subcommand> [options]", nil
|
||||||
}
|
}
|
||||||
cmd := exec.Command("go", args...)
|
cmd := exec.Command("go", args...)
|
||||||
cmd.Dir = cfg.FilePickerDir
|
cmd.Dir = cfg.FilePickerDir
|
||||||
output, err := cmd.CombinedOutput()
|
output, err := cmd.CombinedOutput()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Sprintf("[error] go %s: %v\n%s", args[0], err, string(output)), true
|
return fmt.Sprintf("[error] go %s: %v\n%s", args[0], err, string(output)), nil
|
||||||
}
|
}
|
||||||
return string(output), true
|
return string(output), nil
|
||||||
case "cp":
|
default:
|
||||||
if len(args) < 2 {
|
return "", errors.New("not a builtin")
|
||||||
return "[error] usage: cp <source> <dest>", true
|
|
||||||
}
|
}
|
||||||
src := args[0]
|
if strings.HasPrefix(result, "[error]") {
|
||||||
dst := args[1]
|
return result, errors.New(result)
|
||||||
if !filepath.IsAbs(src) {
|
|
||||||
src = filepath.Join(cfg.FilePickerDir, src)
|
|
||||||
}
|
}
|
||||||
if !filepath.IsAbs(dst) {
|
return result, nil
|
||||||
dst = filepath.Join(cfg.FilePickerDir, dst)
|
|
||||||
}
|
}
|
||||||
data, err := os.ReadFile(src)
|
|
||||||
|
// 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 {
|
if err != nil {
|
||||||
return fmt.Sprintf("[error] cp: %v", err), true
|
return "", err
|
||||||
}
|
}
|
||||||
err = os.WriteFile(dst, data, 0644)
|
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 {
|
if err != nil {
|
||||||
return fmt.Sprintf("[error] cp: %v", err), true
|
return err
|
||||||
}
|
}
|
||||||
return "Copied " + src + " to " + dst, true
|
defer f.Close()
|
||||||
case "mv":
|
_, err = f.WriteString(content)
|
||||||
if len(args) < 2 {
|
return err
|
||||||
return "[error] usage: mv <source> <dest>", true
|
|
||||||
}
|
}
|
||||||
src := args[0]
|
|
||||||
dst := args[1]
|
// humanSizeChain returns human-readable file size
|
||||||
if !filepath.IsAbs(src) {
|
func humanSizeChain(n int64) string {
|
||||||
src = filepath.Join(cfg.FilePickerDir, src)
|
switch {
|
||||||
}
|
case n >= 1<<20:
|
||||||
if !filepath.IsAbs(dst) {
|
return fmt.Sprintf("%.1fMB", float64(n)/float64(1<<20))
|
||||||
dst = filepath.Join(cfg.FilePickerDir, dst)
|
case n >= 1<<10:
|
||||||
}
|
return fmt.Sprintf("%.1fKB", float64(n)/float64(1<<10))
|
||||||
err := os.Rename(src, dst)
|
default:
|
||||||
if err != nil {
|
return fmt.Sprintf("%dB", n)
|
||||||
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 "", false
|
|
||||||
}
|
|
||||||
|
|||||||
550
tools/fs.go
550
tools/fs.go
@@ -9,6 +9,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -61,16 +62,17 @@ func resolvePath(rel string) (string, error) {
|
|||||||
if cfg.FilePickerDir == "" {
|
if cfg.FilePickerDir == "" {
|
||||||
return "", errors.New("fs root not set")
|
return "", errors.New("fs root not set")
|
||||||
}
|
}
|
||||||
if filepath.IsAbs(rel) {
|
isAbs := filepath.IsAbs(rel)
|
||||||
|
if isAbs {
|
||||||
abs := filepath.Clean(rel)
|
abs := filepath.Clean(rel)
|
||||||
if !strings.HasPrefix(abs, cfg.FilePickerDir+string(os.PathSeparator)) && abs != cfg.FilePickerDir {
|
if !cfg.FSAllowOutOfRoot && !strings.HasPrefix(abs, cfg.FilePickerDir+string(os.PathSeparator)) && abs != cfg.FilePickerDir {
|
||||||
return "", fmt.Errorf("path escapes fs root: %s", rel)
|
return "", fmt.Errorf("path escapes fs root: %s", rel)
|
||||||
}
|
}
|
||||||
return abs, nil
|
return abs, nil
|
||||||
}
|
}
|
||||||
abs := filepath.Join(cfg.FilePickerDir, rel)
|
abs := filepath.Join(cfg.FilePickerDir, rel)
|
||||||
abs = filepath.Clean(abs)
|
abs = filepath.Clean(abs)
|
||||||
if !strings.HasPrefix(abs, cfg.FilePickerDir+string(os.PathSeparator)) && abs != cfg.FilePickerDir {
|
if !cfg.FSAllowOutOfRoot && !strings.HasPrefix(abs, cfg.FilePickerDir+string(os.PathSeparator)) && abs != cfg.FilePickerDir {
|
||||||
return "", fmt.Errorf("path escapes fs root: %s", rel)
|
return "", fmt.Errorf("path escapes fs root: %s", rel)
|
||||||
}
|
}
|
||||||
return abs, nil
|
return abs, nil
|
||||||
@@ -93,10 +95,77 @@ func IsImageFile(path string) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func FsLs(args []string, stdin string) string {
|
func FsLs(args []string, stdin string) string {
|
||||||
|
showAll := false
|
||||||
|
longFormat := false
|
||||||
dir := ""
|
dir := ""
|
||||||
if len(args) > 0 {
|
for _, a := range args {
|
||||||
dir = args[0]
|
if strings.HasPrefix(a, "-") && !strings.HasPrefix(a, "--") {
|
||||||
|
flags := strings.TrimLeft(a, "-")
|
||||||
|
for _, c := range flags {
|
||||||
|
switch c {
|
||||||
|
case 'a':
|
||||||
|
showAll = true
|
||||||
|
case 'l':
|
||||||
|
longFormat = true
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
} else if a != "" && dir == "" {
|
||||||
|
dir = a
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hasGlob := strings.ContainsAny(dir, "*?[")
|
||||||
|
|
||||||
|
if hasGlob {
|
||||||
|
absDir := cfg.FilePickerDir
|
||||||
|
if filepath.IsAbs(dir) {
|
||||||
|
absDir = filepath.Dir(dir)
|
||||||
|
} else if strings.Contains(dir, "/") {
|
||||||
|
absDir = filepath.Join(cfg.FilePickerDir, filepath.Dir(dir))
|
||||||
|
}
|
||||||
|
globPattern := filepath.Base(dir)
|
||||||
|
fullPattern := filepath.Join(absDir, globPattern)
|
||||||
|
|
||||||
|
matches, err := filepath.Glob(fullPattern)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Sprintf("[error] ls: %v", err)
|
||||||
|
}
|
||||||
|
if len(matches) == 0 {
|
||||||
|
return "[error] ls: no such file or directory"
|
||||||
|
}
|
||||||
|
var out strings.Builder
|
||||||
|
filter := func(name string) bool {
|
||||||
|
return showAll || !strings.HasPrefix(name, ".")
|
||||||
|
}
|
||||||
|
for _, match := range matches {
|
||||||
|
info, err := os.Stat(match)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name := filepath.Base(match)
|
||||||
|
if !filter(name) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if longFormat {
|
||||||
|
if info.IsDir() {
|
||||||
|
fmt.Fprintf(&out, "d %-8s %s/\n", "-", name)
|
||||||
|
} else {
|
||||||
|
fmt.Fprintf(&out, "f %-8s %s\n", humanSize(info.Size()), name)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if info.IsDir() {
|
||||||
|
fmt.Fprintf(&out, "%s/\n", name)
|
||||||
|
} else {
|
||||||
|
fmt.Fprintf(&out, "%s\n", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if out.Len() == 0 {
|
||||||
|
return "(empty directory)"
|
||||||
|
}
|
||||||
|
return strings.TrimRight(out.String(), "\n")
|
||||||
|
}
|
||||||
|
|
||||||
abs, err := resolvePath(dir)
|
abs, err := resolvePath(dir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Sprintf("[error] %v", err)
|
return fmt.Sprintf("[error] %v", err)
|
||||||
@@ -106,15 +175,29 @@ func FsLs(args []string, stdin string) string {
|
|||||||
return fmt.Sprintf("[error] ls: %v", err)
|
return fmt.Sprintf("[error] ls: %v", err)
|
||||||
}
|
}
|
||||||
var out strings.Builder
|
var out strings.Builder
|
||||||
|
filter := func(name string) bool {
|
||||||
|
return showAll || !strings.HasPrefix(name, ".")
|
||||||
|
}
|
||||||
for _, e := range entries {
|
for _, e := range entries {
|
||||||
|
name := e.Name()
|
||||||
|
if !filter(name) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
info, _ := e.Info()
|
info, _ := e.Info()
|
||||||
switch {
|
if longFormat {
|
||||||
case e.IsDir():
|
if e.IsDir() {
|
||||||
fmt.Fprintf(&out, "d %-8s %s/\n", "-", e.Name())
|
fmt.Fprintf(&out, "d %-8s %s/\n", "-", name)
|
||||||
case info != nil:
|
} else if info != nil {
|
||||||
fmt.Fprintf(&out, "f %-8s %s\n", humanSize(info.Size()), e.Name())
|
fmt.Fprintf(&out, "f %-8s %s\n", humanSize(info.Size()), name)
|
||||||
default:
|
} else {
|
||||||
fmt.Fprintf(&out, "f %-8s %s\n", "?", e.Name())
|
fmt.Fprintf(&out, "f %-8s %s\n", "?", name)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if e.IsDir() {
|
||||||
|
fmt.Fprintf(&out, "%s/\n", name)
|
||||||
|
} else {
|
||||||
|
fmt.Fprintf(&out, "%s\n", name)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if out.Len() == 0 {
|
if out.Len() == 0 {
|
||||||
@@ -125,17 +208,40 @@ func FsLs(args []string, stdin string) string {
|
|||||||
|
|
||||||
func FsCat(args []string, stdin string) string {
|
func FsCat(args []string, stdin string) string {
|
||||||
b64 := false
|
b64 := false
|
||||||
var path string
|
var paths []string
|
||||||
for _, a := range args {
|
for _, a := range args {
|
||||||
if a == "-b" || a == "--base64" {
|
if a == "-b" || a == "--base64" {
|
||||||
b64 = true
|
b64 = true
|
||||||
} else if path == "" {
|
} else if a != "" {
|
||||||
path = a
|
paths = append(paths, a)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if path == "" {
|
if len(paths) == 0 {
|
||||||
return "[error] usage: cat <path>"
|
if stdin != "" {
|
||||||
|
return stdin
|
||||||
}
|
}
|
||||||
|
return "[error] usage: cat <path> or cat (with stdin)"
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
abs, err := resolvePath(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Sprintf("[error] %v", err)
|
return fmt.Sprintf("[error] %v", err)
|
||||||
@@ -149,9 +255,12 @@ func FsCat(args []string, stdin string) string {
|
|||||||
if IsImageFile(path) {
|
if IsImageFile(path) {
|
||||||
result += fmt.Sprintf("\n", abs)
|
result += fmt.Sprintf("\n", abs)
|
||||||
}
|
}
|
||||||
return result
|
results = append(results, result)
|
||||||
|
} else {
|
||||||
|
results = append(results, string(data))
|
||||||
}
|
}
|
||||||
return string(data)
|
}
|
||||||
|
return strings.Join(results, "")
|
||||||
}
|
}
|
||||||
|
|
||||||
func FsViewImg(args []string, stdin string) string {
|
func FsViewImg(args []string, stdin string) string {
|
||||||
@@ -193,11 +302,6 @@ func FsViewImg(args []string, stdin string) string {
|
|||||||
return string(jsonResult)
|
return string(jsonResult)
|
||||||
}
|
}
|
||||||
|
|
||||||
// FsSee is deprecated, use FsViewImg
|
|
||||||
func FsSee(args []string, stdin string) string {
|
|
||||||
return FsViewImg(args, stdin)
|
|
||||||
}
|
|
||||||
|
|
||||||
func FsWrite(args []string, stdin string) string {
|
func FsWrite(args []string, stdin string) string {
|
||||||
b64 := false
|
b64 := false
|
||||||
var path string
|
var path string
|
||||||
@@ -297,25 +401,85 @@ func FsRm(args []string, stdin string) string {
|
|||||||
if len(args) == 0 {
|
if len(args) == 0 {
|
||||||
return "[error] usage: rm <path>"
|
return "[error] usage: rm <path>"
|
||||||
}
|
}
|
||||||
abs, err := resolvePath(args[0])
|
force := false
|
||||||
if err != nil {
|
var paths []string
|
||||||
return fmt.Sprintf("[error] %v", err)
|
for _, a := range args {
|
||||||
|
if a == "-f" || a == "--force" {
|
||||||
|
force = true
|
||||||
|
} else if !strings.HasPrefix(a, "-") {
|
||||||
|
paths = append(paths, a)
|
||||||
}
|
}
|
||||||
if err := os.RemoveAll(abs); err != nil {
|
}
|
||||||
|
if len(paths) == 0 {
|
||||||
|
return "[error] usage: rm <path>"
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
return fmt.Sprintf("[error] rm: %v", err)
|
||||||
}
|
}
|
||||||
return "Removed " + args[0]
|
continue
|
||||||
|
}
|
||||||
|
for _, m := range matches {
|
||||||
|
if err := os.RemoveAll(m); err != nil {
|
||||||
|
if !force {
|
||||||
|
errs = append(errs, fmt.Sprintf("%v", err))
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
removed = append(removed, m)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
abs, err := resolvePath(path)
|
||||||
|
if err != nil {
|
||||||
|
if !force {
|
||||||
|
return fmt.Sprintf("[error] %v", err)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := os.RemoveAll(abs); err != nil {
|
||||||
|
if !force {
|
||||||
|
return fmt.Sprintf("[error] rm: %v", err)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
removed = append(removed, path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(removed) == 0 && len(errs) > 0 {
|
||||||
|
return "[error] rm: " + strings.Join(errs, "; ")
|
||||||
|
}
|
||||||
|
return "Removed " + strings.Join(removed, ", ")
|
||||||
}
|
}
|
||||||
|
|
||||||
func FsCp(args []string, stdin string) string {
|
func FsCp(args []string, stdin string) string {
|
||||||
if len(args) < 2 {
|
if len(args) < 2 {
|
||||||
return "[error] usage: cp <src> <dst>"
|
return "[error] usage: cp <src> <dst>"
|
||||||
}
|
}
|
||||||
srcAbs, err := resolvePath(args[0])
|
srcPattern := args[0]
|
||||||
if err != nil {
|
dstPath := args[1]
|
||||||
return fmt.Sprintf("[error] %v", err)
|
|
||||||
|
// Check if dst is an existing directory (ends with / or is a directory)
|
||||||
|
dstIsDir := strings.HasSuffix(dstPath, "/")
|
||||||
|
if !dstIsDir {
|
||||||
|
if info, err := os.Stat(dstPath); err == nil && info.IsDir() {
|
||||||
|
dstIsDir = true
|
||||||
}
|
}
|
||||||
dstAbs, err := resolvePath(args[1])
|
}
|
||||||
|
|
||||||
|
// 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 {
|
if err != nil {
|
||||||
return fmt.Sprintf("[error] %v", err)
|
return fmt.Sprintf("[error] %v", err)
|
||||||
}
|
}
|
||||||
@@ -323,24 +487,113 @@ func FsCp(args []string, stdin string) string {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Sprintf("[error] cp read: %v", err)
|
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))))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
if err := os.MkdirAll(filepath.Dir(dstAbs), 0o755); err != nil {
|
||||||
return fmt.Sprintf("[error] cp mkdir: %v", err)
|
return fmt.Sprintf("[error] cp mkdir: %v", err)
|
||||||
}
|
}
|
||||||
if err := os.WriteFile(dstAbs, data, 0o644); err != nil {
|
if err := os.WriteFile(dstAbs, data, 0o644); err != nil {
|
||||||
return fmt.Sprintf("[error] cp write: %v", err)
|
return fmt.Sprintf("[error] cp write: %v", err)
|
||||||
}
|
}
|
||||||
return fmt.Sprintf("Copied %s → %s (%s)", args[0], args[1], humanSize(int64(len(data))))
|
results = append(results, fmt.Sprintf("%s → %s (%s)", srcPath, filepath.Join(dstPath, filepath.Base(srcPath)), humanSize(int64(len(data)))))
|
||||||
|
}
|
||||||
|
return strings.Join(results, ", ")
|
||||||
}
|
}
|
||||||
|
|
||||||
func FsMv(args []string, stdin string) string {
|
func FsMv(args []string, stdin string) string {
|
||||||
if len(args) < 2 {
|
if len(args) < 2 {
|
||||||
return "[error] usage: mv <src> <dst>"
|
return "[error] usage: mv <src> <dst>"
|
||||||
}
|
}
|
||||||
srcAbs, err := resolvePath(args[0])
|
srcPattern := args[0]
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 {
|
if err != nil {
|
||||||
return fmt.Sprintf("[error] %v", err)
|
return fmt.Sprintf("[error] %v", err)
|
||||||
}
|
}
|
||||||
dstAbs, err := resolvePath(args[1])
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
if err != nil {
|
||||||
return fmt.Sprintf("[error] %v", err)
|
return fmt.Sprintf("[error] %v", err)
|
||||||
}
|
}
|
||||||
@@ -350,7 +603,9 @@ func FsMv(args []string, stdin string) string {
|
|||||||
if err := os.Rename(srcAbs, dstAbs); err != nil {
|
if err := os.Rename(srcAbs, dstAbs); err != nil {
|
||||||
return fmt.Sprintf("[error] mv: %v", err)
|
return fmt.Sprintf("[error] mv: %v", err)
|
||||||
}
|
}
|
||||||
return fmt.Sprintf("Moved %s → %s", args[0], args[1])
|
results = append(results, fmt.Sprintf("%s → %s", srcPath, filepath.Join(dstPath, filepath.Base(srcPath))))
|
||||||
|
}
|
||||||
|
return strings.Join(results, ", ")
|
||||||
}
|
}
|
||||||
|
|
||||||
func FsMkdir(args []string, stdin string) string {
|
func FsMkdir(args []string, stdin string) string {
|
||||||
@@ -394,7 +649,11 @@ func FsEcho(args []string, stdin string) string {
|
|||||||
if stdin != "" {
|
if stdin != "" {
|
||||||
return stdin
|
return stdin
|
||||||
}
|
}
|
||||||
return strings.Join(args, " ")
|
result := strings.Join(args, " ")
|
||||||
|
if result != "" {
|
||||||
|
result += "\n"
|
||||||
|
}
|
||||||
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
func FsTime(args []string, stdin string) string {
|
func FsTime(args []string, stdin string) string {
|
||||||
@@ -403,13 +662,31 @@ func FsTime(args []string, stdin string) string {
|
|||||||
|
|
||||||
func FsGrep(args []string, stdin string) string {
|
func FsGrep(args []string, stdin string) string {
|
||||||
if len(args) == 0 {
|
if len(args) == 0 {
|
||||||
return "[error] usage: grep [-i] [-v] [-c] <pattern>"
|
return "[error] usage: grep [-i] [-v] [-c] [-E] <pattern> [file]"
|
||||||
}
|
}
|
||||||
ignoreCase := false
|
ignoreCase := false
|
||||||
invert := false
|
invert := false
|
||||||
countOnly := false
|
countOnly := false
|
||||||
|
useRegex := false
|
||||||
var pattern string
|
var pattern string
|
||||||
|
var filePath string
|
||||||
for _, a := range args {
|
for _, a := range args {
|
||||||
|
if strings.HasPrefix(a, "-") && !strings.HasPrefix(a, "--") && len(a) > 1 {
|
||||||
|
flags := strings.TrimLeft(a, "-")
|
||||||
|
for _, c := range flags {
|
||||||
|
switch c {
|
||||||
|
case 'i':
|
||||||
|
ignoreCase = true
|
||||||
|
case 'v':
|
||||||
|
invert = true
|
||||||
|
case 'c':
|
||||||
|
countOnly = true
|
||||||
|
case 'E':
|
||||||
|
useRegex = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
switch a {
|
switch a {
|
||||||
case "-i":
|
case "-i":
|
||||||
ignoreCase = true
|
ignoreCase = true
|
||||||
@@ -417,24 +694,61 @@ func FsGrep(args []string, stdin string) string {
|
|||||||
invert = true
|
invert = true
|
||||||
case "-c":
|
case "-c":
|
||||||
countOnly = true
|
countOnly = true
|
||||||
|
case "-E":
|
||||||
|
useRegex = true
|
||||||
default:
|
default:
|
||||||
|
if pattern == "" {
|
||||||
pattern = a
|
pattern = a
|
||||||
|
} else if filePath == "" {
|
||||||
|
filePath = a
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if pattern == "" {
|
if pattern == "" {
|
||||||
return "[error] pattern required"
|
return "[error] pattern required"
|
||||||
}
|
}
|
||||||
if ignoreCase {
|
var lines []string
|
||||||
pattern = strings.ToLower(pattern)
|
if filePath != "" {
|
||||||
|
abs, err := resolvePath(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Sprintf("[error] grep: %v", err)
|
||||||
}
|
}
|
||||||
lines := strings.Split(stdin, "\n")
|
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)"
|
||||||
|
}
|
||||||
|
|
||||||
var matched []string
|
var matched []string
|
||||||
for _, line := range lines {
|
for _, line := range lines {
|
||||||
|
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
|
haystack := line
|
||||||
if ignoreCase {
|
if ignoreCase {
|
||||||
haystack = strings.ToLower(line)
|
haystack = strings.ToLower(line)
|
||||||
|
patternLower := strings.ToLower(pattern)
|
||||||
|
match = strings.Contains(haystack, patternLower)
|
||||||
|
} else {
|
||||||
|
match = strings.Contains(haystack, pattern)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
match := strings.Contains(haystack, pattern)
|
|
||||||
if invert {
|
if invert {
|
||||||
match = !match
|
match = !match
|
||||||
}
|
}
|
||||||
@@ -450,6 +764,7 @@ func FsGrep(args []string, stdin string) string {
|
|||||||
|
|
||||||
func FsHead(args []string, stdin string) string {
|
func FsHead(args []string, stdin string) string {
|
||||||
n := 10
|
n := 10
|
||||||
|
var filePath string
|
||||||
for i, a := range args {
|
for i, a := range args {
|
||||||
if a == "-n" && i+1 < len(args) {
|
if a == "-n" && i+1 < len(args) {
|
||||||
if parsed, err := strconv.Atoi(args[i+1]); err == nil {
|
if parsed, err := strconv.Atoi(args[i+1]); err == nil {
|
||||||
@@ -459,9 +774,26 @@ func FsHead(args []string, stdin string) string {
|
|||||||
continue
|
continue
|
||||||
} else if parsed, err := strconv.Atoi(a); err == nil {
|
} else if parsed, err := strconv.Atoi(a); err == nil {
|
||||||
n = parsed
|
n = parsed
|
||||||
|
} else if filePath == "" && !strings.HasPrefix(a, "-") {
|
||||||
|
filePath = a
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
lines := strings.Split(stdin, "\n")
|
var lines []string
|
||||||
|
if filePath != "" {
|
||||||
|
abs, err := resolvePath(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Sprintf("[error] head: %v", err)
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(abs)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Sprintf("[error] head: %v", err)
|
||||||
|
}
|
||||||
|
lines = strings.Split(string(data), "\n")
|
||||||
|
} else if stdin != "" {
|
||||||
|
lines = strings.Split(stdin, "\n")
|
||||||
|
} else {
|
||||||
|
return "[error] head: no input (use file path or pipe from stdin)"
|
||||||
|
}
|
||||||
if n > 0 && len(lines) > n {
|
if n > 0 && len(lines) > n {
|
||||||
lines = lines[:n]
|
lines = lines[:n]
|
||||||
}
|
}
|
||||||
@@ -470,6 +802,7 @@ func FsHead(args []string, stdin string) string {
|
|||||||
|
|
||||||
func FsTail(args []string, stdin string) string {
|
func FsTail(args []string, stdin string) string {
|
||||||
n := 10
|
n := 10
|
||||||
|
var filePath string
|
||||||
for i, a := range args {
|
for i, a := range args {
|
||||||
if a == "-n" && i+1 < len(args) {
|
if a == "-n" && i+1 < len(args) {
|
||||||
if parsed, err := strconv.Atoi(args[i+1]); err == nil {
|
if parsed, err := strconv.Atoi(args[i+1]); err == nil {
|
||||||
@@ -479,9 +812,29 @@ func FsTail(args []string, stdin string) string {
|
|||||||
continue
|
continue
|
||||||
} else if parsed, err := strconv.Atoi(a); err == nil {
|
} else if parsed, err := strconv.Atoi(a); err == nil {
|
||||||
n = parsed
|
n = parsed
|
||||||
|
} else if filePath == "" && !strings.HasPrefix(a, "-") {
|
||||||
|
filePath = a
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
lines := strings.Split(stdin, "\n")
|
var lines []string
|
||||||
|
if filePath != "" {
|
||||||
|
abs, err := resolvePath(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Sprintf("[error] tail: %v", err)
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(abs)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Sprintf("[error] tail: %v", err)
|
||||||
|
}
|
||||||
|
lines = strings.Split(string(data), "\n")
|
||||||
|
} else if stdin != "" {
|
||||||
|
lines = strings.Split(stdin, "\n")
|
||||||
|
} else {
|
||||||
|
return "[error] tail: no input (use file path or pipe from stdin)"
|
||||||
|
}
|
||||||
|
for len(lines) > 0 && lines[len(lines)-1] == "" {
|
||||||
|
lines = lines[:len(lines)-1]
|
||||||
|
}
|
||||||
if n > 0 && len(lines) > n {
|
if n > 0 && len(lines) > n {
|
||||||
lines = lines[len(lines)-n:]
|
lines = lines[len(lines)-n:]
|
||||||
}
|
}
|
||||||
@@ -489,9 +842,35 @@ func FsTail(args []string, stdin string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func FsWc(args []string, stdin string) string {
|
func FsWc(args []string, stdin string) string {
|
||||||
lines := len(strings.Split(stdin, "\n"))
|
var content string
|
||||||
words := len(strings.Fields(stdin))
|
var filePath string
|
||||||
chars := len(stdin)
|
for _, a := range args {
|
||||||
|
if strings.HasPrefix(a, "-") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if filePath == "" {
|
||||||
|
filePath = a
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if filePath != "" {
|
||||||
|
abs, err := resolvePath(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Sprintf("[error] wc: %v", err)
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(abs)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Sprintf("[error] wc: %v", err)
|
||||||
|
}
|
||||||
|
content = string(data)
|
||||||
|
} else if stdin != "" {
|
||||||
|
content = stdin
|
||||||
|
} else {
|
||||||
|
return "[error] wc: no input (use file path or pipe from stdin)"
|
||||||
|
}
|
||||||
|
content = strings.TrimRight(content, "\n")
|
||||||
|
lines := len(strings.Split(content, "\n"))
|
||||||
|
words := len(strings.Fields(content))
|
||||||
|
chars := len(content)
|
||||||
if len(args) > 0 {
|
if len(args) > 0 {
|
||||||
switch args[0] {
|
switch args[0] {
|
||||||
case "-l":
|
case "-l":
|
||||||
@@ -506,17 +885,40 @@ func FsWc(args []string, stdin string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func FsSort(args []string, stdin string) string {
|
func FsSort(args []string, stdin string) string {
|
||||||
lines := strings.Split(stdin, "\n")
|
|
||||||
reverse := false
|
reverse := false
|
||||||
numeric := false
|
numeric := false
|
||||||
|
var filePath string
|
||||||
for _, a := range args {
|
for _, a := range args {
|
||||||
switch a {
|
switch a {
|
||||||
case "-r":
|
case "-r":
|
||||||
reverse = true
|
reverse = true
|
||||||
case "-n":
|
case "-n":
|
||||||
numeric = true
|
numeric = true
|
||||||
|
default:
|
||||||
|
if filePath == "" && !strings.HasPrefix(a, "-") {
|
||||||
|
filePath = a
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
var lines []string
|
||||||
|
if filePath != "" {
|
||||||
|
abs, err := resolvePath(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Sprintf("[error] sort: %v", err)
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(abs)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Sprintf("[error] sort: %v", err)
|
||||||
|
}
|
||||||
|
lines = strings.Split(string(data), "\n")
|
||||||
|
} else if stdin != "" {
|
||||||
|
lines = strings.Split(stdin, "\n")
|
||||||
|
} else {
|
||||||
|
return "[error] sort: no input (use file path or pipe from stdin)"
|
||||||
|
}
|
||||||
|
for len(lines) > 0 && lines[len(lines)-1] == "" {
|
||||||
|
lines = lines[:len(lines)-1]
|
||||||
|
}
|
||||||
sortFunc := func(i, j int) bool {
|
sortFunc := func(i, j int) bool {
|
||||||
if numeric {
|
if numeric {
|
||||||
ni, _ := strconv.Atoi(lines[i])
|
ni, _ := strconv.Atoi(lines[i])
|
||||||
@@ -536,37 +938,47 @@ func FsSort(args []string, stdin string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func FsUniq(args []string, stdin string) string {
|
func FsUniq(args []string, stdin string) string {
|
||||||
lines := strings.Split(stdin, "\n")
|
|
||||||
showCount := false
|
showCount := false
|
||||||
|
var filePath string
|
||||||
for _, a := range args {
|
for _, a := range args {
|
||||||
if a == "-c" {
|
if a == "-c" {
|
||||||
showCount = true
|
showCount = true
|
||||||
|
} else if filePath == "" && !strings.HasPrefix(a, "-") {
|
||||||
|
filePath = a
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
var lines []string
|
||||||
|
if filePath != "" {
|
||||||
|
abs, err := resolvePath(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Sprintf("[error] uniq: %v", err)
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(abs)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Sprintf("[error] uniq: %v", err)
|
||||||
|
}
|
||||||
|
lines = strings.Split(string(data), "\n")
|
||||||
|
} else if stdin != "" {
|
||||||
|
lines = strings.Split(stdin, "\n")
|
||||||
|
} else {
|
||||||
|
return "[error] uniq: no input (use file path or pipe from stdin)"
|
||||||
|
}
|
||||||
var result []string
|
var result []string
|
||||||
var prev string
|
seen := make(map[string]bool)
|
||||||
first := true
|
countMap := make(map[string]int)
|
||||||
count := 0
|
|
||||||
for _, line := range lines {
|
for _, line := range lines {
|
||||||
if first || line != prev {
|
countMap[line]++
|
||||||
if !first && showCount {
|
if !seen[line] {
|
||||||
result = append(result, fmt.Sprintf("%d %s", count, prev))
|
seen[line] = true
|
||||||
} else if !first {
|
result = append(result, line)
|
||||||
result = append(result, prev)
|
|
||||||
}
|
|
||||||
count = 1
|
|
||||||
prev = line
|
|
||||||
first = false
|
|
||||||
} else {
|
|
||||||
count++
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !first {
|
|
||||||
if showCount {
|
if showCount {
|
||||||
result = append(result, fmt.Sprintf("%d %s", count, prev))
|
var counted []string
|
||||||
} else {
|
for _, line := range result {
|
||||||
result = append(result, prev)
|
counted = append(counted, fmt.Sprintf("%d %s", countMap[line], line))
|
||||||
}
|
}
|
||||||
|
return strings.Join(counted, "\n")
|
||||||
}
|
}
|
||||||
return strings.Join(result, "\n")
|
return strings.Join(result, "\n")
|
||||||
}
|
}
|
||||||
@@ -653,7 +1065,7 @@ func FsSed(args []string, stdin string) string {
|
|||||||
return "[error] usage: sed 's/old/new/[g]' [file]"
|
return "[error] usage: sed 's/old/new/[g]' [file]"
|
||||||
}
|
}
|
||||||
// Parse pattern: s/old/new/flags
|
// Parse pattern: s/old/new/flags
|
||||||
parts := strings.Split(pattern[1:], "/")
|
parts := strings.Split(pattern[2:], "/")
|
||||||
if len(parts) < 2 {
|
if len(parts) < 2 {
|
||||||
return "[error] invalid sed pattern. Use: s/old/new/[g]"
|
return "[error] invalid sed pattern. Use: s/old/new/[g]"
|
||||||
}
|
}
|
||||||
|
|||||||
423
tools/fs_test.go
Normal file
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
590
tools/tools.go
590
tools/tools.go
@@ -35,7 +35,12 @@ Your current tools:
|
|||||||
{
|
{
|
||||||
"name":"run",
|
"name":"run",
|
||||||
"args": ["command"],
|
"args": ["command"],
|
||||||
"when_to_use": "Main tool for file operations, shell commands, memory, git, and todo. Use run \"help\" for all commands. Examples: run \"ls -la\", run \"help\", run \"mkdir -p foo/bar\", run \"cat file.txt\", run \"view_img image.png\", run \"git status\", run \"memory store foo bar\", run \"todo create task\", run \"grep pattern file\", run \"cd /path\", run \"pwd\", run \"find . -name *.txt\", run \"file image.png\", run \"head file\", run \"tail file\", run \"wc -l file\", run \"sort file\", run \"uniq file\", run \"sed 's/old/new/' file\", run \"echo text\", run \"go build ./...\", run \"time\", run \"stat file\", run \"cp src dst\", run \"mv src dst\", run \"rm file\""
|
"when_to_use": "Main tool for file operations, shell commands, memory, git, and todo. Use run "help" for all commands. Examples: run "ls -la", run "help", run "mkdir -p foo/bar", run "cat file.txt", run "write file.txt content", run "git status", run "memory store foo bar", run "todo create task", run "grep pattern file", run "cd /path", run "pwd", run "find . -name *.txt", run "file image.png", run "head file", run "tail file", run "wc -l file", run "sort file", run "uniq file", run "sed 's/old/new/' file", run "echo text", run "go build ./...", run "time", run "stat file", run "cp src dst", run "mv src dst", run "rm file"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name":"browser",
|
||||||
|
"args": ["action", "args"],
|
||||||
|
"when_to_use": "Playwright browser automation. Actions: start, stop, running, go <url>, click <selector>, fill <selector> <text>, text [selector], html [selector], screenshot [path], screenshot_and_view, wait <selector>, drag <x1> <y1> <x2> <y2>. Example: browser start, browser go https://example.com, browser click #submit-button"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name":"view_img",
|
"name":"view_img",
|
||||||
@@ -61,11 +66,6 @@ Your current tools:
|
|||||||
"name":"read_url_raw",
|
"name":"read_url_raw",
|
||||||
"args": ["url"],
|
"args": ["url"],
|
||||||
"when_to_use": "get raw content from a webpage"
|
"when_to_use": "get raw content from a webpage"
|
||||||
},
|
|
||||||
{
|
|
||||||
"name":"browser_agent",
|
|
||||||
"args": ["task"],
|
|
||||||
"when_to_use": "autonomous browser automation for complex multi-step tasks like login, form filling, scraping"
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
</tools>
|
</tools>
|
||||||
@@ -206,7 +206,7 @@ func (t *Tools) GetWebAgentClient() *agent.AgentClient {
|
|||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
t.webAgentClient = agent.NewAgentClient(cfg, logger, getToken)
|
t.webAgentClient = agent.NewAgentClient(t.cfg, t.logger, getToken)
|
||||||
})
|
})
|
||||||
return t.webAgentClient
|
return t.webAgentClient
|
||||||
}
|
}
|
||||||
@@ -408,7 +408,7 @@ func runCmd(args map[string]string) []byte {
|
|||||||
case "browser":
|
case "browser":
|
||||||
// browser <action> [args...] - Playwright browser automation
|
// browser <action> [args...] - Playwright browser automation
|
||||||
return runBrowserCommand(rest, args)
|
return runBrowserCommand(rest, args)
|
||||||
case "mkdir", "ls", "cat", "pwd", "cd", "cp", "mv", "rm", "sed", "grep", "head", "tail", "wc", "sort", "uniq", "echo", "time", "stat", "go", "find", "file":
|
case "mkdir", "ls", "cat", "write", "stat", "pwd", "cd", "cp", "mv", "rm", "sed", "grep", "head", "tail", "wc", "sort", "uniq", "echo", "time", "go", "find", "file":
|
||||||
// File operations and shell commands - use ExecChain which has whitelist
|
// File operations and shell commands - use ExecChain which has whitelist
|
||||||
return executeCommand(args)
|
return executeCommand(args)
|
||||||
case "git":
|
case "git":
|
||||||
@@ -420,6 +420,36 @@ func runCmd(args map[string]string) []byte {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// browserCmd handles top-level browser tool calls
|
||||||
|
func browserCmd(args map[string]string) []byte {
|
||||||
|
action, _ := args["action"]
|
||||||
|
argsStr, _ := args["args"]
|
||||||
|
// Parse args string into slice (space-separated, respecting quoted strings)
|
||||||
|
var browserArgs []string
|
||||||
|
if argsStr != "" {
|
||||||
|
browserArgs = strings.Fields(argsStr)
|
||||||
|
}
|
||||||
|
if action == "" {
|
||||||
|
return []byte(`usage: browser <action> [args...]
|
||||||
|
Actions:
|
||||||
|
start - start browser
|
||||||
|
stop - stop browser
|
||||||
|
running - check if running
|
||||||
|
go <url> - navigate to URL
|
||||||
|
click <selector> - click element
|
||||||
|
fill <selector> <text> - fill input
|
||||||
|
text [selector] - extract text
|
||||||
|
html [selector] - get HTML
|
||||||
|
screenshot [path] - take screenshot
|
||||||
|
screenshot_and_view - take and view screenshot
|
||||||
|
wait <selector> - wait for element
|
||||||
|
drag <from> <to> - drag element`)
|
||||||
|
}
|
||||||
|
// Prepend action to args for runBrowserCommand
|
||||||
|
fullArgs := append([]string{action}, browserArgs...)
|
||||||
|
return runBrowserCommand(fullArgs, args)
|
||||||
|
}
|
||||||
|
|
||||||
// runBrowserCommand routes browser subcommands to Playwright handlers
|
// runBrowserCommand routes browser subcommands to Playwright handlers
|
||||||
func runBrowserCommand(args []string, originalArgs map[string]string) []byte {
|
func runBrowserCommand(args []string, originalArgs map[string]string) []byte {
|
||||||
if len(args) == 0 {
|
if len(args) == 0 {
|
||||||
@@ -543,6 +573,33 @@ Actions:
|
|||||||
"fromSelector": rest[0],
|
"fromSelector": rest[0],
|
||||||
"toSelector": rest[1],
|
"toSelector": rest[1],
|
||||||
})
|
})
|
||||||
|
case "help":
|
||||||
|
return []byte(`browser <action> [args]
|
||||||
|
Playwright browser automation.
|
||||||
|
Actions:
|
||||||
|
start - start browser
|
||||||
|
stop - stop browser
|
||||||
|
running - check if browser is running
|
||||||
|
go <url> - navigate to URL
|
||||||
|
click <selector> - click element (use index for multiple: click #btn 1)
|
||||||
|
fill <sel> <text> - fill input field
|
||||||
|
text [selector] - extract text (from element or whole page)
|
||||||
|
html [selector] - get HTML (from element or whole page)
|
||||||
|
screenshot [path] - take screenshot
|
||||||
|
screenshot_and_view - take and view screenshot
|
||||||
|
wait <selector> - wait for element to appear
|
||||||
|
drag <x1> <y1> <x2> <y2> - drag by coordinates
|
||||||
|
drag <sel1> <sel2> - drag by selectors (center points)
|
||||||
|
Examples:
|
||||||
|
browser start
|
||||||
|
browser go https://example.com
|
||||||
|
browser click #submit-button
|
||||||
|
browser fill #search-input hello
|
||||||
|
browser text
|
||||||
|
browser screenshot
|
||||||
|
browser screenshot_and_view
|
||||||
|
browser drag 100 200 300 400
|
||||||
|
browser drag #item1 #container2`)
|
||||||
default:
|
default:
|
||||||
return []byte("unknown browser action: " + action)
|
return []byte("unknown browser action: " + action)
|
||||||
}
|
}
|
||||||
@@ -602,20 +659,6 @@ func getHelp(args []string) string {
|
|||||||
capture <name> - capture a window screenshot
|
capture <name> - capture a window screenshot
|
||||||
capture_and_view <name> - capture and view screenshot
|
capture_and_view <name> - capture and view screenshot
|
||||||
|
|
||||||
# Browser (requires Playwright)
|
|
||||||
browser start - start browser
|
|
||||||
browser stop - stop browser
|
|
||||||
browser running - check if running
|
|
||||||
browser go <url> - navigate to URL
|
|
||||||
browser click <sel> - click element
|
|
||||||
browser fill <sel> <txt> - fill input
|
|
||||||
browser text [sel] - extract text
|
|
||||||
browser html [sel] - get HTML
|
|
||||||
browser screenshot - take screenshot
|
|
||||||
browser wait <sel> - wait for element
|
|
||||||
browser drag <x1> <y1> <x2> <y2> - drag by coordinates
|
|
||||||
browser drag <sel1> <sel2> - drag by selectors (center points)
|
|
||||||
|
|
||||||
# System
|
# System
|
||||||
<any shell command> - run shell command directly
|
<any shell command> - run shell command directly
|
||||||
|
|
||||||
@@ -752,31 +795,6 @@ Use: run "command" to execute.`
|
|||||||
Requires: xdotool and maim
|
Requires: xdotool and maim
|
||||||
Examples:
|
Examples:
|
||||||
run "capture_and_view Firefox"`
|
run "capture_and_view Firefox"`
|
||||||
case "browser":
|
|
||||||
return `browser <action> [args]
|
|
||||||
Playwright browser automation.
|
|
||||||
Requires: Playwright browser server running
|
|
||||||
Actions:
|
|
||||||
start - start browser
|
|
||||||
stop - stop browser
|
|
||||||
running - check if browser is running
|
|
||||||
go <url> - navigate to URL
|
|
||||||
click <selector> - click element (use index for multiple: click #btn 1)
|
|
||||||
fill <selector> <text> - fill input field
|
|
||||||
text [selector] - extract text (from element or whole page)
|
|
||||||
html [selector] - get HTML (from element or whole page)
|
|
||||||
screenshot [path] - take screenshot
|
|
||||||
wait <selector> - wait for element to appear
|
|
||||||
drag <from> <to> - drag element to another element
|
|
||||||
Examples:
|
|
||||||
run "browser start"
|
|
||||||
run "browser go https://example.com"
|
|
||||||
run "browser click #submit-button"
|
|
||||||
run "browser fill #search-input hello"
|
|
||||||
run "browser text"
|
|
||||||
run "browser screenshot"
|
|
||||||
run "browser drag 100 200 300 400"
|
|
||||||
run "browser drag #item1 #container2"`
|
|
||||||
default:
|
default:
|
||||||
return fmt.Sprintf("No help available for: %s. Use: run \"help\" for all commands.", cmd)
|
return fmt.Sprintf("No help available for: %s. Use: run \"help\" for all commands.", cmd)
|
||||||
}
|
}
|
||||||
@@ -1269,6 +1287,8 @@ var FnMap = map[string]fnSig{
|
|||||||
"help": helpTool,
|
"help": helpTool,
|
||||||
// Unified run command
|
// Unified run command
|
||||||
"run": runCmd,
|
"run": runCmd,
|
||||||
|
// Browser tool - routes to runBrowserCommand
|
||||||
|
"browser": browserCmd,
|
||||||
"summarize_chat": summarizeChat,
|
"summarize_chat": summarizeChat,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1298,467 +1318,13 @@ func summarizeChat(args map[string]string) []byte {
|
|||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
// func removePlaywrightToolsFromBaseTools() {
|
|
||||||
// playwrightToolNames := map[string]bool{
|
|
||||||
// "pw_start": true,
|
|
||||||
// "pw_stop": true,
|
|
||||||
// "pw_is_running": true,
|
|
||||||
// "pw_navigate": true,
|
|
||||||
// "pw_click": true,
|
|
||||||
// "pw_click_at": true,
|
|
||||||
// "pw_fill": true,
|
|
||||||
// "pw_extract_text": true,
|
|
||||||
// "pw_screenshot": true,
|
|
||||||
// "pw_screenshot_and_view": true,
|
|
||||||
// "pw_wait_for_selector": true,
|
|
||||||
// "pw_drag": true,
|
|
||||||
// }
|
|
||||||
// var filtered []models.Tool
|
|
||||||
// for _, tool := range BaseTools {
|
|
||||||
// if !playwrightToolNames[tool.Function.Name] {
|
|
||||||
// filtered = append(filtered, tool)
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
// BaseTools = filtered
|
|
||||||
// delete(FnMap, "pw_start")
|
|
||||||
// delete(FnMap, "pw_stop")
|
|
||||||
// delete(FnMap, "pw_is_running")
|
|
||||||
// delete(FnMap, "pw_navigate")
|
|
||||||
// delete(FnMap, "pw_click")
|
|
||||||
// delete(FnMap, "pw_click_at")
|
|
||||||
// delete(FnMap, "pw_fill")
|
|
||||||
// delete(FnMap, "pw_extract_text")
|
|
||||||
// delete(FnMap, "pw_screenshot")
|
|
||||||
// delete(FnMap, "pw_screenshot_and_view")
|
|
||||||
// delete(FnMap, "pw_wait_for_selector")
|
|
||||||
// delete(FnMap, "pw_drag")
|
|
||||||
// }
|
|
||||||
|
|
||||||
// func (t *Tools) RegisterWindowTools(modelHasVision bool) {
|
|
||||||
// removeWindowToolsFromBaseTools()
|
|
||||||
// if t.WindowToolsAvailable {
|
|
||||||
// FnMap["list_windows"] = listWindows
|
|
||||||
// FnMap["capture_window"] = captureWindow
|
|
||||||
// windowTools := []models.Tool{
|
|
||||||
// {
|
|
||||||
// Type: "function",
|
|
||||||
// Function: models.ToolFunc{
|
|
||||||
// Name: "list_windows",
|
|
||||||
// Description: "List all visible windows with their IDs and names. Returns a map of window ID to window name.",
|
|
||||||
// Parameters: models.ToolFuncParams{
|
|
||||||
// Type: "object",
|
|
||||||
// Required: []string{},
|
|
||||||
// Properties: map[string]models.ToolArgProps{},
|
|
||||||
// },
|
|
||||||
// },
|
|
||||||
// },
|
|
||||||
// {
|
|
||||||
// Type: "function",
|
|
||||||
// Function: models.ToolFunc{
|
|
||||||
// Name: "capture_window",
|
|
||||||
// Description: "Capture a screenshot of a specific window and save it to /tmp. Requires window parameter (window ID or name substring).",
|
|
||||||
// Parameters: models.ToolFuncParams{
|
|
||||||
// Type: "object",
|
|
||||||
// Required: []string{"window"},
|
|
||||||
// Properties: map[string]models.ToolArgProps{
|
|
||||||
// "window": models.ToolArgProps{
|
|
||||||
// Type: "string",
|
|
||||||
// Description: "window ID or window name (partial match)",
|
|
||||||
// },
|
|
||||||
// },
|
|
||||||
// },
|
|
||||||
// },
|
|
||||||
// },
|
|
||||||
// }
|
|
||||||
// if modelHasVision {
|
|
||||||
// FnMap["capture_window_and_view"] = captureWindowAndView
|
|
||||||
// windowTools = append(windowTools, models.Tool{
|
|
||||||
// Type: "function",
|
|
||||||
// Function: models.ToolFunc{
|
|
||||||
// Name: "capture_window_and_view",
|
|
||||||
// Description: "Capture a screenshot of a specific window, save it to /tmp, and return the image for viewing. Requires window parameter (window ID or name substring).",
|
|
||||||
// Parameters: models.ToolFuncParams{
|
|
||||||
// Type: "object",
|
|
||||||
// Required: []string{"window"},
|
|
||||||
// Properties: map[string]models.ToolArgProps{
|
|
||||||
// "window": models.ToolArgProps{
|
|
||||||
// Type: "string",
|
|
||||||
// Description: "window ID or window name (partial match)",
|
|
||||||
// },
|
|
||||||
// },
|
|
||||||
// },
|
|
||||||
// },
|
|
||||||
// })
|
|
||||||
// }
|
|
||||||
// BaseTools = append(BaseTools, windowTools...)
|
|
||||||
// ToolSysMsg += windowToolSysMsg
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
// for pw agentA
|
// for pw agentA
|
||||||
// var browserAgentSysPrompt = `You are an autonomous browser automation agent. Your goal is to complete the user's task by intelligently using browser automation
|
// var browserAgentSysPrompt = `You are an autonomous browser automation agent. Your goal is to complete the user's task by intelligently using browser automation
|
||||||
|
|
||||||
// Important: The browser may already be running from a previous task! Always check pw_is_running first before starting a new browser.
|
// Important: The browser may already be running from a previous task! Always check pw_is_running first before starting a new browser.
|
||||||
|
|
||||||
// Available tools:
|
|
||||||
// - pw_start: Start browser (only if not already running)
|
|
||||||
// - pw_stop: Stop browser (only when you're truly done and browser is no longer needed)
|
|
||||||
// - pw_is_running: Check if browser is running
|
|
||||||
// - pw_navigate: Go to a URL
|
|
||||||
// - pw_click: Click an element by CSS selector
|
|
||||||
// - pw_fill: Type text into an input
|
|
||||||
// - pw_extract_text: Get text from page/element
|
|
||||||
// - pw_screenshot: Take a screenshot (returns file path)
|
|
||||||
// - pw_screenshot_and_view: Take screenshot with image for viewing
|
|
||||||
// - pw_wait_for_selector: Wait for element to appear
|
|
||||||
// - pw_drag: Drag mouse from one point to another
|
|
||||||
// - pw_click_at: Click at X,Y coordinates
|
|
||||||
// - pw_get_html: Get HTML content
|
|
||||||
// - pw_get_dom: Get structured DOM tree
|
|
||||||
// - pw_search_elements: Search for elements by text or selector
|
|
||||||
|
|
||||||
// Workflow:
|
|
||||||
// 1. First, check if browser is already running (pw_is_running)
|
|
||||||
// 2. Only start browser if not already running (pw_start)
|
|
||||||
// 3. Navigate to required pages (pw_navigate)
|
|
||||||
// 4. Interact with elements as needed (click, fill, etc.)
|
|
||||||
// 5. Extract information or take screenshots as requested
|
|
||||||
// 6. IMPORTANT: Do NOT stop the browser when done! Leave it running so the user can continue interacting with the page in subsequent requests.
|
|
||||||
|
|
||||||
// Always provide clear feedback about what you're doing and what you found.`
|
// Always provide clear feedback about what you're doing and what you found.`
|
||||||
|
|
||||||
// func (t *Tools) runBrowserAgent(args map[string]string) []byte {
|
|
||||||
// task, ok := args["task"]
|
|
||||||
// if !ok || task == "" {
|
|
||||||
// return []byte(`{"error": "task argument is required"}`)
|
|
||||||
// }
|
|
||||||
// client := t.GetWebAgentClient()
|
|
||||||
// pwAgent := agent.NewPWAgent(client, browserAgentSysPrompt)
|
|
||||||
// pwAgent.SetTools(agent.GetPWTools())
|
|
||||||
// return pwAgent.ProcessTask(task)
|
|
||||||
// }
|
|
||||||
|
|
||||||
// func registerPlaywrightTools() {
|
|
||||||
// removePlaywrightToolsFromBaseTools()
|
|
||||||
// if cfg != nil && cfg.PlaywrightEnabled {
|
|
||||||
// FnMap["pw_start"] = pwStart
|
|
||||||
// FnMap["pw_stop"] = pwStop
|
|
||||||
// FnMap["pw_is_running"] = pwIsRunning
|
|
||||||
// FnMap["pw_navigate"] = pwNavigate
|
|
||||||
// FnMap["pw_click"] = pwClick
|
|
||||||
// FnMap["pw_click_at"] = pwClickAt
|
|
||||||
// FnMap["pw_fill"] = pwFill
|
|
||||||
// FnMap["pw_extract_text"] = pwExtractText
|
|
||||||
// FnMap["pw_screenshot"] = pwScreenshot
|
|
||||||
// FnMap["pw_screenshot_and_view"] = pwScreenshotAndView
|
|
||||||
// FnMap["pw_wait_for_selector"] = pwWaitForSelector
|
|
||||||
// FnMap["pw_drag"] = pwDrag
|
|
||||||
// FnMap["pw_get_html"] = pwGetHTML
|
|
||||||
// FnMap["pw_get_dom"] = pwGetDOM
|
|
||||||
// FnMap["pw_search_elements"] = pwSearchElements
|
|
||||||
// playwrightTools := []models.Tool{
|
|
||||||
// {
|
|
||||||
// Type: "function",
|
|
||||||
// Function: models.ToolFunc{
|
|
||||||
// Name: "pw_start",
|
|
||||||
// Description: "Start a Playwright browser instance. Call this first before using other pw_ Uses headless mode by default (set PlaywrightHeadless=false in config for GUI).",
|
|
||||||
// Parameters: models.ToolFuncParams{
|
|
||||||
// Type: "object",
|
|
||||||
// Required: []string{},
|
|
||||||
// Properties: map[string]models.ToolArgProps{},
|
|
||||||
// },
|
|
||||||
// },
|
|
||||||
// },
|
|
||||||
// {
|
|
||||||
// Type: "function",
|
|
||||||
// Function: models.ToolFunc{
|
|
||||||
// Name: "pw_stop",
|
|
||||||
// Description: "Stop the Playwright browser instance. Call when done with browser automation.",
|
|
||||||
// Parameters: models.ToolFuncParams{
|
|
||||||
// Type: "object",
|
|
||||||
// Required: []string{},
|
|
||||||
// Properties: map[string]models.ToolArgProps{},
|
|
||||||
// },
|
|
||||||
// },
|
|
||||||
// },
|
|
||||||
// {
|
|
||||||
// Type: "function",
|
|
||||||
// Function: models.ToolFunc{
|
|
||||||
// Name: "pw_is_running",
|
|
||||||
// Description: "Check if Playwright browser is currently running.",
|
|
||||||
// Parameters: models.ToolFuncParams{
|
|
||||||
// Type: "object",
|
|
||||||
// Required: []string{},
|
|
||||||
// Properties: map[string]models.ToolArgProps{},
|
|
||||||
// },
|
|
||||||
// },
|
|
||||||
// },
|
|
||||||
// {
|
|
||||||
// Type: "function",
|
|
||||||
// Function: models.ToolFunc{
|
|
||||||
// Name: "pw_navigate",
|
|
||||||
// Description: "Navigate to a URL in the browser.",
|
|
||||||
// Parameters: models.ToolFuncParams{
|
|
||||||
// Type: "object",
|
|
||||||
// Required: []string{"url"},
|
|
||||||
// Properties: map[string]models.ToolArgProps{
|
|
||||||
// "url": models.ToolArgProps{
|
|
||||||
// Type: "string",
|
|
||||||
// Description: "URL to navigate to",
|
|
||||||
// },
|
|
||||||
// },
|
|
||||||
// },
|
|
||||||
// },
|
|
||||||
// },
|
|
||||||
// {
|
|
||||||
// Type: "function",
|
|
||||||
// Function: models.ToolFunc{
|
|
||||||
// Name: "pw_click",
|
|
||||||
// Description: "Click on an element using CSS selector. Use 'index' for multiple matches (default 0).",
|
|
||||||
// Parameters: models.ToolFuncParams{
|
|
||||||
// Type: "object",
|
|
||||||
// Required: []string{"selector"},
|
|
||||||
// Properties: map[string]models.ToolArgProps{
|
|
||||||
// "selector": models.ToolArgProps{
|
|
||||||
// Type: "string",
|
|
||||||
// Description: "CSS selector for the element to click",
|
|
||||||
// },
|
|
||||||
// "index": models.ToolArgProps{
|
|
||||||
// Type: "string",
|
|
||||||
// Description: "optional index for multiple matches (default 0)",
|
|
||||||
// },
|
|
||||||
// },
|
|
||||||
// },
|
|
||||||
// },
|
|
||||||
// },
|
|
||||||
// {
|
|
||||||
// Type: "function",
|
|
||||||
// Function: models.ToolFunc{
|
|
||||||
// Name: "pw_fill",
|
|
||||||
// Description: "Fill an input field with text using CSS selector.",
|
|
||||||
// Parameters: models.ToolFuncParams{
|
|
||||||
// Type: "object",
|
|
||||||
// Required: []string{"selector", "text"},
|
|
||||||
// Properties: map[string]models.ToolArgProps{
|
|
||||||
// "selector": models.ToolArgProps{
|
|
||||||
// Type: "string",
|
|
||||||
// Description: "CSS selector for the input element",
|
|
||||||
// },
|
|
||||||
// "text": models.ToolArgProps{
|
|
||||||
// Type: "string",
|
|
||||||
// Description: "text to fill into the input",
|
|
||||||
// },
|
|
||||||
// "index": models.ToolArgProps{
|
|
||||||
// Type: "string",
|
|
||||||
// Description: "optional index for multiple matches (default 0)",
|
|
||||||
// },
|
|
||||||
// },
|
|
||||||
// },
|
|
||||||
// },
|
|
||||||
// },
|
|
||||||
// {
|
|
||||||
// Type: "function",
|
|
||||||
// Function: models.ToolFunc{
|
|
||||||
// Name: "pw_extract_text",
|
|
||||||
// Description: "Extract text content from the page or specific elements using CSS selector. Use 'body' for all page text.",
|
|
||||||
// Parameters: models.ToolFuncParams{
|
|
||||||
// Type: "object",
|
|
||||||
// Required: []string{"selector"},
|
|
||||||
// Properties: map[string]models.ToolArgProps{
|
|
||||||
// "selector": models.ToolArgProps{
|
|
||||||
// Type: "string",
|
|
||||||
// Description: "CSS selector (use 'body' for all page text)",
|
|
||||||
// },
|
|
||||||
// },
|
|
||||||
// },
|
|
||||||
// },
|
|
||||||
// },
|
|
||||||
// {
|
|
||||||
// Type: "function",
|
|
||||||
// Function: models.ToolFunc{
|
|
||||||
// Name: "pw_screenshot",
|
|
||||||
// Description: "Take a screenshot of the page or a specific element. Returns file path to saved image.",
|
|
||||||
// Parameters: models.ToolFuncParams{
|
|
||||||
// Type: "object",
|
|
||||||
// Required: []string{},
|
|
||||||
// Properties: map[string]models.ToolArgProps{
|
|
||||||
// "selector": models.ToolArgProps{
|
|
||||||
// Type: "string",
|
|
||||||
// Description: "optional CSS selector for element to screenshot",
|
|
||||||
// },
|
|
||||||
// "full_page": models.ToolArgProps{
|
|
||||||
// Type: "string",
|
|
||||||
// Description: "optional: 'true' to capture full page (default false)",
|
|
||||||
// },
|
|
||||||
// },
|
|
||||||
// },
|
|
||||||
// },
|
|
||||||
// },
|
|
||||||
// {
|
|
||||||
// Type: "function",
|
|
||||||
// Function: models.ToolFunc{
|
|
||||||
// Name: "pw_screenshot_and_view",
|
|
||||||
// Description: "Take a screenshot and return the image for viewing. Use when model needs to see the screenshot.",
|
|
||||||
// Parameters: models.ToolFuncParams{
|
|
||||||
// Type: "object",
|
|
||||||
// Required: []string{},
|
|
||||||
// Properties: map[string]models.ToolArgProps{
|
|
||||||
// "selector": models.ToolArgProps{
|
|
||||||
// Type: "string",
|
|
||||||
// Description: "optional CSS selector for element to screenshot",
|
|
||||||
// },
|
|
||||||
// "full_page": models.ToolArgProps{
|
|
||||||
// Type: "string",
|
|
||||||
// Description: "optional: 'true' to capture full page (default false)",
|
|
||||||
// },
|
|
||||||
// },
|
|
||||||
// },
|
|
||||||
// },
|
|
||||||
// },
|
|
||||||
// {
|
|
||||||
// Type: "function",
|
|
||||||
// Function: models.ToolFunc{
|
|
||||||
// Name: "pw_wait_for_selector",
|
|
||||||
// Description: "Wait for an element to appear on the page.",
|
|
||||||
// Parameters: models.ToolFuncParams{
|
|
||||||
// Type: "object",
|
|
||||||
// Required: []string{"selector"},
|
|
||||||
// Properties: map[string]models.ToolArgProps{
|
|
||||||
// "selector": models.ToolArgProps{
|
|
||||||
// Type: "string",
|
|
||||||
// Description: "CSS selector to wait for",
|
|
||||||
// },
|
|
||||||
// "timeout": models.ToolArgProps{
|
|
||||||
// Type: "string",
|
|
||||||
// Description: "optional timeout in ms (default 30000)",
|
|
||||||
// },
|
|
||||||
// },
|
|
||||||
// },
|
|
||||||
// },
|
|
||||||
// },
|
|
||||||
// {
|
|
||||||
// Type: "function",
|
|
||||||
// Function: models.ToolFunc{
|
|
||||||
// Name: "pw_drag",
|
|
||||||
// Description: "Drag the mouse from one point to another.",
|
|
||||||
// Parameters: models.ToolFuncParams{
|
|
||||||
// Type: "object",
|
|
||||||
// Required: []string{"x1", "y1", "x2", "y2"},
|
|
||||||
// Properties: map[string]models.ToolArgProps{
|
|
||||||
// "x1": models.ToolArgProps{
|
|
||||||
// Type: "string",
|
|
||||||
// Description: "starting X coordinate",
|
|
||||||
// },
|
|
||||||
// "y1": models.ToolArgProps{
|
|
||||||
// Type: "string",
|
|
||||||
// Description: "starting Y coordinate",
|
|
||||||
// },
|
|
||||||
// "x2": models.ToolArgProps{
|
|
||||||
// Type: "string",
|
|
||||||
// Description: "ending X coordinate",
|
|
||||||
// },
|
|
||||||
// "y2": models.ToolArgProps{
|
|
||||||
// Type: "string",
|
|
||||||
// Description: "ending Y coordinate",
|
|
||||||
// },
|
|
||||||
// },
|
|
||||||
// },
|
|
||||||
// },
|
|
||||||
// },
|
|
||||||
// {
|
|
||||||
// Type: "function",
|
|
||||||
// Function: models.ToolFunc{
|
|
||||||
// Name: "pw_get_html",
|
|
||||||
// Description: "Get the HTML content of the page or a specific element.",
|
|
||||||
// Parameters: models.ToolFuncParams{
|
|
||||||
// Type: "object",
|
|
||||||
// Required: []string{},
|
|
||||||
// Properties: map[string]models.ToolArgProps{
|
|
||||||
// "selector": models.ToolArgProps{
|
|
||||||
// Type: "string",
|
|
||||||
// Description: "optional CSS selector (default: body)",
|
|
||||||
// },
|
|
||||||
// },
|
|
||||||
// },
|
|
||||||
// },
|
|
||||||
// },
|
|
||||||
// {
|
|
||||||
// Type: "function",
|
|
||||||
// Function: models.ToolFunc{
|
|
||||||
// Name: "pw_get_dom",
|
|
||||||
// Description: "Get a structured DOM representation of an element with tag, attributes, text, and children.",
|
|
||||||
// Parameters: models.ToolFuncParams{
|
|
||||||
// Type: "object",
|
|
||||||
// Required: []string{},
|
|
||||||
// Properties: map[string]models.ToolArgProps{
|
|
||||||
// "selector": models.ToolArgProps{
|
|
||||||
// Type: "string",
|
|
||||||
// Description: "optional CSS selector (default: body)",
|
|
||||||
// },
|
|
||||||
// },
|
|
||||||
// },
|
|
||||||
// },
|
|
||||||
// },
|
|
||||||
// {
|
|
||||||
// Type: "function",
|
|
||||||
// Function: models.ToolFunc{
|
|
||||||
// Name: "pw_search_elements",
|
|
||||||
// Description: "Search for elements by text content or CSS selector. Returns matching elements with their tags, text, and HTML.",
|
|
||||||
// Parameters: models.ToolFuncParams{
|
|
||||||
// Type: "object",
|
|
||||||
// Required: []string{},
|
|
||||||
// Properties: map[string]models.ToolArgProps{
|
|
||||||
// "text": models.ToolArgProps{
|
|
||||||
// Type: "string",
|
|
||||||
// Description: "text to search for in elements",
|
|
||||||
// },
|
|
||||||
// "selector": models.ToolArgProps{
|
|
||||||
// Type: "string",
|
|
||||||
// Description: "CSS selector to search for",
|
|
||||||
// },
|
|
||||||
// },
|
|
||||||
// },
|
|
||||||
// },
|
|
||||||
// },
|
|
||||||
// }
|
|
||||||
// BaseTools = append(BaseTools, playwrightTools...)
|
|
||||||
// ToolSysMsg += browserToolSysMsg
|
|
||||||
// agent.RegisterPWTool("pw_start", pwStart)
|
|
||||||
// agent.RegisterPWTool("pw_stop", pwStop)
|
|
||||||
// agent.RegisterPWTool("pw_is_running", pwIsRunning)
|
|
||||||
// agent.RegisterPWTool("pw_navigate", pwNavigate)
|
|
||||||
// agent.RegisterPWTool("pw_click", pwClick)
|
|
||||||
// agent.RegisterPWTool("pw_click_at", pwClickAt)
|
|
||||||
// agent.RegisterPWTool("pw_fill", pwFill)
|
|
||||||
// agent.RegisterPWTool("pw_extract_text", pwExtractText)
|
|
||||||
// agent.RegisterPWTool("pw_screenshot", pwScreenshot)
|
|
||||||
// agent.RegisterPWTool("pw_screenshot_and_view", pwScreenshotAndView)
|
|
||||||
// agent.RegisterPWTool("pw_wait_for_selector", pwWaitForSelector)
|
|
||||||
// agent.RegisterPWTool("pw_drag", pwDrag)
|
|
||||||
// agent.RegisterPWTool("pw_get_html", pwGetHTML)
|
|
||||||
// agent.RegisterPWTool("pw_get_dom", pwGetDOM)
|
|
||||||
// agent.RegisterPWTool("pw_search_elements", pwSearchElements)
|
|
||||||
// browserAgentTool := []models.Tool{
|
|
||||||
// {
|
|
||||||
// Type: "function",
|
|
||||||
// Function: models.ToolFunc{
|
|
||||||
// Name: "browser_agent",
|
|
||||||
// Description: "Autonomous browser automation agent. Use for complex multi-step browser tasks like 'go to website, login, and take screenshot'. The agent will plan and execute steps automatically using browser ",
|
|
||||||
// Parameters: models.ToolFuncParams{
|
|
||||||
// Type: "object",
|
|
||||||
// Required: []string{"task"},
|
|
||||||
// Properties: map[string]models.ToolArgProps{
|
|
||||||
// "task": {Type: "string", Description: "The task to accomplish, e.g., 'go to github.com and take a screenshot of the homepage'"},
|
|
||||||
// },
|
|
||||||
// },
|
|
||||||
// },
|
|
||||||
// },
|
|
||||||
// }
|
|
||||||
// BaseTools = append(BaseTools, browserAgentTool...)
|
|
||||||
// FnMap["browser_agent"] = tooler.runBrowserAgent
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
func CallToolWithAgent(name string, args map[string]string) ([]byte, bool) {
|
func CallToolWithAgent(name string, args map[string]string) ([]byte, bool) {
|
||||||
f, ok := FnMap[name]
|
f, ok := FnMap[name]
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -1911,4 +1477,26 @@ var BaseTools = []models.Tool{
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
// browser - Playwright browser automation
|
||||||
|
models.Tool{
|
||||||
|
Type: "function",
|
||||||
|
Function: models.ToolFunc{
|
||||||
|
Name: "browser",
|
||||||
|
Description: "Playwright browser automation. Actions: start (launch browser), stop (close browser), running (check if browser is running), go <url> (navigate), click <selector> [index] (click element), fill <selector> <text> (type into input), text [selector] (extract text), html [selector] (get HTML), screenshot [path] (take screenshot), screenshot_and_view (take and view), wait <selector> (wait for element), drag <x1> <y1> <x2> <y2> (drag by coords) or drag <sel1> <sel2> (drag by selectors)",
|
||||||
|
Parameters: models.ToolFuncParams{
|
||||||
|
Type: "object",
|
||||||
|
Required: []string{"action"},
|
||||||
|
Properties: map[string]models.ToolArgProps{
|
||||||
|
"action": models.ToolArgProps{
|
||||||
|
Type: "string",
|
||||||
|
Description: "Browser action: start, stop, running, go, click, fill, text, html, screenshot, screenshot_and_view, wait, drag",
|
||||||
|
},
|
||||||
|
"args": models.ToolArgProps{
|
||||||
|
Type: "string",
|
||||||
|
Description: "Arguments for the action (e.g., URL for go, selector for click, etc.)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
316
tools/unix_test.go
Normal file
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"
|
helpPage = "helpPage"
|
||||||
renamePage = "renamePage"
|
renamePage = "renamePage"
|
||||||
RAGPage = "RAGPage"
|
RAGPage = "RAGPage"
|
||||||
|
dbTablesPage = "dbTables"
|
||||||
propsPage = "propsPage"
|
propsPage = "propsPage"
|
||||||
codeBlockPage = "codeBlockPage"
|
codeBlockPage = "codeBlockPage"
|
||||||
imgPage = "imgPage"
|
imgPage = "imgPage"
|
||||||
@@ -143,6 +144,13 @@ func setShellMode(enabled bool) {
|
|||||||
// showToast displays a temporary notification in the bottom-right corner.
|
// showToast displays a temporary notification in the bottom-right corner.
|
||||||
// It auto-hides after 3 seconds.
|
// It auto-hides after 3 seconds.
|
||||||
func showToast(title, message string) {
|
func showToast(title, message string) {
|
||||||
|
if cfg.UseNotifySend {
|
||||||
|
notifySend(title, message)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if cfg.CLIMode {
|
||||||
|
return
|
||||||
|
}
|
||||||
sanitize := func(s string, maxLen int) string {
|
sanitize := func(s string, maxLen int) string {
|
||||||
sanitized := strings.Map(func(r rune) rune {
|
sanitized := strings.Map(func(r rune) rune {
|
||||||
if r < 32 && r != '\t' {
|
if r < 32 && r != '\t' {
|
||||||
@@ -230,7 +238,6 @@ func initTUI() {
|
|||||||
tview.Styles = colorschemes["default"]
|
tview.Styles = colorschemes["default"]
|
||||||
app = tview.NewApplication()
|
app = tview.NewApplication()
|
||||||
pages = tview.NewPages()
|
pages = tview.NewPages()
|
||||||
outputHandler = &TUIOutputHandler{tv: textView}
|
|
||||||
shellInput = tview.NewInputField().
|
shellInput = tview.NewInputField().
|
||||||
SetLabel(fmt.Sprintf("[%s]$ ", cfg.FilePickerDir)). // dynamic prompt
|
SetLabel(fmt.Sprintf("[%s]$ ", cfg.FilePickerDir)). // dynamic prompt
|
||||||
SetFieldWidth(0).
|
SetFieldWidth(0).
|
||||||
@@ -349,6 +356,7 @@ func initTUI() {
|
|||||||
// calling it explicitly makes text streaming to look more smooth
|
// calling it explicitly makes text streaming to look more smooth
|
||||||
app.Draw()
|
app.Draw()
|
||||||
})
|
})
|
||||||
|
outputHandler = &TUIOutputHandler{tv: textView}
|
||||||
notificationWidget = tview.NewTextView().
|
notificationWidget = tview.NewTextView().
|
||||||
SetTextAlign(tview.AlignCenter).
|
SetTextAlign(tview.AlignCenter).
|
||||||
SetDynamicColors(true).
|
SetDynamicColors(true).
|
||||||
@@ -1192,5 +1200,4 @@ func initTUI() {
|
|||||||
}
|
}
|
||||||
return event
|
return event
|
||||||
})
|
})
|
||||||
go updateModelLists()
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user