Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8dbcb1147f | |||
| 86f5ca4aef |
@@ -11,6 +11,7 @@ model/provider switching, and local tools.
|
||||
- Multiple providers from `~/.agentu/config.yaml`.
|
||||
- Session-only provider, model, and thinking-level switching.
|
||||
- Read-only project tools enabled by default.
|
||||
- Built-in web search and URL fetching.
|
||||
- `--yolo` mode for file writes and shell execution.
|
||||
|
||||
## Quick Start
|
||||
@@ -58,6 +59,10 @@ agent:
|
||||
tool_timeout: 30s
|
||||
```
|
||||
|
||||
Web tools are enabled by default with a built-in no-key backend. agentu exposes
|
||||
`web_search` for current or external web information and `web_fetch` for reading
|
||||
a known URL.
|
||||
|
||||
Provider names are the keys under `providers`. At startup, agentu selects the
|
||||
first provider by name. Use `/model provider <name>` to switch providers for the
|
||||
current session.
|
||||
@@ -112,6 +117,11 @@ Read-only tools are available by default:
|
||||
- `file_list`
|
||||
- `file_search`
|
||||
|
||||
Web tools are also available in read-only mode:
|
||||
|
||||
- `web_search`
|
||||
- `web_fetch`
|
||||
|
||||
`--yolo` additionally enables:
|
||||
|
||||
- `file_write`
|
||||
|
||||
@@ -60,6 +60,8 @@ func run() error {
|
||||
} else {
|
||||
registry = tools.ReadOnlyBuiltins(cfg.Agent.WorkingDir)
|
||||
}
|
||||
registry.Register(tools.NewBuiltinSearchTool(http.DefaultClient))
|
||||
registry.Register(tools.NewBuiltinFetchTool(http.DefaultClient))
|
||||
systemPrompt := strings.TrimSpace(cfg.Agent.SystemPrompt + "\n\n" + tools.AgentInstructions(cfg.Agent.WorkingDir, *yolo))
|
||||
|
||||
assistant := agent.New(agent.Options{
|
||||
|
||||
@@ -5,30 +5,42 @@ go 1.26
|
||||
require (
|
||||
github.com/charmbracelet/bubbles v1.0.0
|
||||
github.com/charmbracelet/bubbletea v1.3.10
|
||||
github.com/charmbracelet/lipgloss v1.1.0
|
||||
github.com/charmbracelet/glamour v1.0.0
|
||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834
|
||||
golang.org/x/net v0.38.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/alecthomas/chroma/v2 v2.20.0 // indirect
|
||||
github.com/atotto/clipboard v0.1.4 // indirect
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
github.com/aymerick/douceur v0.2.0 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.4.1 // indirect
|
||||
github.com/charmbracelet/x/ansi v0.11.6 // indirect
|
||||
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
|
||||
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect
|
||||
github.com/charmbracelet/x/term v0.2.2 // indirect
|
||||
github.com/clipperhouse/displaywidth v0.9.0 // indirect
|
||||
github.com/clipperhouse/stringish v0.1.1 // indirect
|
||||
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
|
||||
github.com/dlclark/regexp2 v1.11.5 // indirect
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||
github.com/gorilla/css v1.0.1 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.19 // indirect
|
||||
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||
github.com/muesli/reflow v0.3.0 // indirect
|
||||
github.com/muesli/termenv v0.16.0 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
github.com/yuin/goldmark v1.7.13 // indirect
|
||||
github.com/yuin/goldmark-emoji v1.0.6 // indirect
|
||||
golang.org/x/sys v0.38.0 // indirect
|
||||
golang.org/x/text v0.3.8 // indirect
|
||||
golang.org/x/term v0.36.0 // indirect
|
||||
golang.org/x/text v0.30.0 // indirect
|
||||
)
|
||||
|
||||
@@ -1,23 +1,37 @@
|
||||
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
|
||||
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
|
||||
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
|
||||
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
|
||||
github.com/alecthomas/chroma/v2 v2.20.0 h1:sfIHpxPyR07/Oylvmcai3X/exDlE8+FA820NTz+9sGw=
|
||||
github.com/alecthomas/chroma/v2 v2.20.0/go.mod h1:e7tViK0xh/Nf4BYHl00ycY6rV7b8iXBksI9E359yNmA=
|
||||
github.com/alecthomas/repr v0.5.1 h1:E3G4t2QbHTSNpPKBgMTln5KLkZHLOcU7r37J4pXBuIg=
|
||||
github.com/alecthomas/repr v0.5.1/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
|
||||
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
||||
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||
github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
|
||||
github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
|
||||
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
|
||||
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
||||
github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
|
||||
github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
|
||||
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
|
||||
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
|
||||
github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
|
||||
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
|
||||
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
|
||||
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
|
||||
github.com/charmbracelet/glamour v1.0.0 h1:AWMLOVFHTsysl4WV8T8QgkQ0s/ZNZo7CiE4WKhk8l08=
|
||||
github.com/charmbracelet/glamour v1.0.0/go.mod h1:DSdohgOBkMr2ZQNhw4LZxSGpx3SvpeujNoXrQyH2hxo=
|
||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE=
|
||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA=
|
||||
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
|
||||
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
|
||||
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI=
|
||||
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU=
|
||||
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
|
||||
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
|
||||
github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA=
|
||||
@@ -26,34 +40,55 @@ github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfa
|
||||
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
|
||||
github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
|
||||
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
|
||||
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
|
||||
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
||||
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
|
||||
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
|
||||
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
|
||||
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
||||
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
||||
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
|
||||
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
|
||||
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
|
||||
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
|
||||
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||
github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
|
||||
github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
|
||||
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
||||
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA=
|
||||
github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||
github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs=
|
||||
github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA=
|
||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
|
||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
|
||||
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
|
||||
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
|
||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
|
||||
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||
golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q=
|
||||
golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss=
|
||||
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
|
||||
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
|
||||
+11
-2
@@ -82,6 +82,9 @@ func Builtins(workingDir string) *Registry {
|
||||
}
|
||||
|
||||
func AgentInstructions(workingDir string, yolo bool) string {
|
||||
searchInstructions := webSearchInstructions()
|
||||
readOnlyTools := "file_list, file_read, file_search, web_search, web_fetch"
|
||||
yoloTools := "file_list, file_read, file_search, file_write, shell_run, web_search, web_fetch"
|
||||
if yolo {
|
||||
return fmt.Sprintf(`Local project context:
|
||||
- The project working directory is %q.
|
||||
@@ -92,8 +95,9 @@ func AgentInstructions(workingDir string, yolo bool) string {
|
||||
- When the user asks to run, execute, test, check, debug, or inspect a shell command, use shell_run. This includes local commands written in backticks, such as go, make, git, pwd, ls, ps, and similar terminal commands.
|
||||
- Do not use file tools as a substitute for a command execution request.
|
||||
- Do not start interactive or long-running sessions. Prefer bounded, non-interactive local commands with clear completion conditions.
|
||||
- %s
|
||||
- Tool paths should normally be relative to the project working directory.
|
||||
- Available tools: file_list, file_read, file_search, file_write, shell_run.`, workingDir)
|
||||
- Available tools: %s.`, workingDir, searchInstructions, yoloTools)
|
||||
}
|
||||
return fmt.Sprintf(`Local project context:
|
||||
- The project working directory is %q.
|
||||
@@ -102,8 +106,13 @@ func AgentInstructions(workingDir string, yolo bool) string {
|
||||
- Do not explore the project preemptively. Do not call file_list just to discover context unless the user asks about project files or the answer truly depends on local files.
|
||||
- When the user asks to inspect, list, search, or read project files, use file_list, file_search, and file_read instead of saying you cannot access local files.
|
||||
- Shell command execution is disabled in this mode. When the user asks to run, execute, test, check, debug, or inspect a shell command, do not use file tools as a substitute; explain that they need to restart agentu with --yolo to enable shell_run.
|
||||
- %s
|
||||
- Tool paths should normally be relative to the project working directory.
|
||||
- Available tools: file_list, file_read, file_search.`, workingDir)
|
||||
- Available tools: %s.`, workingDir, searchInstructions, readOnlyTools)
|
||||
}
|
||||
|
||||
func webSearchInstructions() string {
|
||||
return "When the user's request depends on current or external web information, use web_search. This includes news, prices, laws, schedules, software versions, product recommendations, or requests for the latest information. Use web_fetch to read a source URL when search snippets are not enough, when the user provides a URL, or when the user names a specific website to inspect. Base answers on returned sources and include source URLs when useful."
|
||||
}
|
||||
|
||||
func JSONSchema(schema string) json.RawMessage {
|
||||
|
||||
@@ -84,6 +84,27 @@ func TestReadOnlyBuiltinsExposeOpenAICompatibleNamesAndAliases(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryCanExposeWebSearch(t *testing.T) {
|
||||
registry := ReadOnlyBuiltins(t.TempDir())
|
||||
registry.Register(NewBuiltinSearchTool(nil))
|
||||
registry.Register(NewBuiltinFetchTool(nil))
|
||||
|
||||
var names []string
|
||||
for _, def := range registry.Definitions() {
|
||||
names = append(names, def.Function.Name)
|
||||
}
|
||||
for _, want := range []string{"web_search", "web_fetch"} {
|
||||
if !slices.Contains(names, want) {
|
||||
t.Fatalf("definitions missing %s: %#v", want, names)
|
||||
}
|
||||
}
|
||||
for _, want := range []string{"web_search", "web_fetch"} {
|
||||
if _, ok := registry.Get(want); !ok {
|
||||
t.Fatalf("%s missing", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuiltinsExposeYoloTools(t *testing.T) {
|
||||
registry := Builtins(t.TempDir())
|
||||
for _, name := range []string{"file_write", "shell_run", "file.write", "shell.run"} {
|
||||
@@ -108,3 +129,12 @@ func TestAgentInstructionsExplainCommandModes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentInstructionsExplainWebSearch(t *testing.T) {
|
||||
withSearch := AgentInstructions("/tmp/project", false)
|
||||
for _, want := range []string{"use web_search", "Use web_fetch", "latest information", "source URLs", "Available tools: file_list, file_read, file_search, web_search, web_fetch"} {
|
||||
if !strings.Contains(withSearch, want) {
|
||||
t.Fatalf("web search instructions missing %q:\n%s", want, withSearch)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,597 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"agentu/internal/llm"
|
||||
|
||||
"golang.org/x/net/html"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultSearchBaseURL = "https://html.duckduckgo.com/html/"
|
||||
defaultUserAgent = "agentu/0.0.3"
|
||||
maxSearchResults = 20
|
||||
maxWebReadBytes = 2 * 1024 * 1024
|
||||
)
|
||||
|
||||
type BuiltinSearchTool struct {
|
||||
baseURL string
|
||||
maxResults int
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func NewBuiltinSearchTool(client *http.Client) *BuiltinSearchTool {
|
||||
return NewBuiltinSearchToolWithBaseURL(defaultSearchBaseURL, 5, client)
|
||||
}
|
||||
|
||||
func NewBuiltinSearchToolWithBaseURL(baseURL string, maxResults int, client *http.Client) *BuiltinSearchTool {
|
||||
if strings.TrimSpace(baseURL) == "" {
|
||||
baseURL = defaultSearchBaseURL
|
||||
}
|
||||
if maxResults <= 0 {
|
||||
maxResults = 5
|
||||
}
|
||||
if client == nil {
|
||||
client = http.DefaultClient
|
||||
}
|
||||
return &BuiltinSearchTool{
|
||||
baseURL: baseURL,
|
||||
maxResults: maxResults,
|
||||
client: client,
|
||||
}
|
||||
}
|
||||
|
||||
func (t *BuiltinSearchTool) Definition() llm.Tool {
|
||||
return webSearchDefinition()
|
||||
}
|
||||
|
||||
func webSearchDefinition() llm.Tool {
|
||||
return llm.Tool{
|
||||
Type: "function",
|
||||
Function: llm.ToolFunction{
|
||||
Name: "web_search",
|
||||
Description: "Search the public web for current or external information. Use for recent facts, news, prices, schedules, versions, policies, recommendations, or other questions that need online sources. Results include source URLs.",
|
||||
Parameters: JSONSchema(`{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {"type": "string", "description": "Search query."},
|
||||
"max_results": {"type": "integer", "description": "Optional result count from 1 to 20. Defaults to 5.", "minimum": 1, "maximum": 20},
|
||||
"topic": {"type": "string", "description": "Optional topic hint. Accepted for compatibility but the built-in backend may ignore it.", "enum": ["general", "news", "finance"]}
|
||||
},
|
||||
"required": ["query"],
|
||||
"additionalProperties": false
|
||||
}`),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (t *BuiltinSearchTool) Execute(ctx context.Context, raw json.RawMessage) (string, error) {
|
||||
args, err := parseWebSearchArgs(raw, t.maxResults)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
endpoint, err := url.Parse(t.baseURL)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("parse search endpoint: %w", err)
|
||||
}
|
||||
query := endpoint.Query()
|
||||
query.Set("q", args.Query)
|
||||
endpoint.RawQuery = query.Encode()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create search request: %w", err)
|
||||
}
|
||||
req.Header.Set("User-Agent", defaultUserAgent)
|
||||
req.Header.Set("Accept", "text/html,application/xhtml+xml")
|
||||
|
||||
resp, err := t.client.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("search request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 8*1024))
|
||||
return "", fmt.Errorf("web search failed: status %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
}
|
||||
|
||||
results, err := parseSearchResults(resp.Body, args.MaxResults)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return FormatResult(formatWebSearchResults(args.Query, "", results)), nil
|
||||
}
|
||||
|
||||
type BuiltinFetchTool struct {
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
type webSearchArgs struct {
|
||||
Query string `json:"query"`
|
||||
MaxResults int `json:"max_results"`
|
||||
Topic string `json:"topic"`
|
||||
}
|
||||
|
||||
type webSearchResult struct {
|
||||
Title string `json:"title"`
|
||||
URL string `json:"url"`
|
||||
Content string `json:"content"`
|
||||
Score float64 `json:"score"`
|
||||
}
|
||||
|
||||
type webFetchArgs struct {
|
||||
URL string `json:"url"`
|
||||
Query string `json:"query"`
|
||||
}
|
||||
|
||||
type webFetchResponse struct {
|
||||
Results []webFetchResult `json:"results"`
|
||||
FailedResults []webFetchFailed `json:"failed_results"`
|
||||
}
|
||||
|
||||
type webFetchResult struct {
|
||||
URL string `json:"url"`
|
||||
RawContent string `json:"raw_content"`
|
||||
}
|
||||
|
||||
type webFetchFailed struct {
|
||||
URL string `json:"url"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
func NewBuiltinFetchTool(client *http.Client) *BuiltinFetchTool {
|
||||
if client == nil {
|
||||
client = http.DefaultClient
|
||||
}
|
||||
return &BuiltinFetchTool{client: client}
|
||||
}
|
||||
|
||||
func (t *BuiltinFetchTool) Definition() llm.Tool {
|
||||
return webFetchDefinition()
|
||||
}
|
||||
|
||||
func webFetchDefinition() llm.Tool {
|
||||
return llm.Tool{
|
||||
Type: "function",
|
||||
Function: llm.ToolFunction{
|
||||
Name: "web_fetch",
|
||||
Description: "Fetch and extract readable Markdown content from a public web URL. Use after web_search when snippets are not enough, or when the user gives a URL or website to inspect. Results include the source URL.",
|
||||
Parameters: JSONSchema(`{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"url": {"type": "string", "description": "Public http or https URL to fetch. URLs without a scheme are treated as https URLs."},
|
||||
"query": {"type": "string", "description": "Optional extraction focus. Accepted for compatibility but the built-in backend may ignore it."}
|
||||
},
|
||||
"required": ["url"],
|
||||
"additionalProperties": false
|
||||
}`),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (t *BuiltinFetchTool) Execute(ctx context.Context, raw json.RawMessage) (string, error) {
|
||||
args, err := parseWebFetchArgs(raw)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, args.URL, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create fetch request: %w", err)
|
||||
}
|
||||
req.Header.Set("User-Agent", defaultUserAgent)
|
||||
req.Header.Set("Accept", "text/html,text/plain,application/xhtml+xml")
|
||||
|
||||
resp, err := t.client.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("fetch request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 8*1024))
|
||||
return "", fmt.Errorf("web fetch failed: status %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(io.LimitReader(resp.Body, maxWebReadBytes))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read response: %w", err)
|
||||
}
|
||||
contentType := strings.ToLower(resp.Header.Get("Content-Type"))
|
||||
var content string
|
||||
if strings.Contains(contentType, "html") || contentType == "" {
|
||||
content, err = htmlToMarkdown(string(data), args.URL)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
} else if strings.Contains(contentType, "text/plain") || strings.Contains(contentType, "markdown") {
|
||||
content = strings.TrimSpace(string(data))
|
||||
} else {
|
||||
return "", fmt.Errorf("unsupported content type: %s", contentType)
|
||||
}
|
||||
|
||||
result := webFetchResponse{
|
||||
Results: []webFetchResult{{
|
||||
URL: args.URL,
|
||||
RawContent: content,
|
||||
}},
|
||||
}
|
||||
return FormatResult(formatWebFetchResults(args.URL, result)), nil
|
||||
}
|
||||
|
||||
func parseWebSearchArgs(raw json.RawMessage, defaultMaxResults int) (webSearchArgs, error) {
|
||||
var args webSearchArgs
|
||||
if err := json.Unmarshal(raw, &args); err != nil {
|
||||
return args, fmt.Errorf("parse arguments: %w", err)
|
||||
}
|
||||
args.Query = strings.TrimSpace(args.Query)
|
||||
if args.Query == "" {
|
||||
return args, errors.New("query is required")
|
||||
}
|
||||
if args.MaxResults == 0 {
|
||||
args.MaxResults = defaultMaxResults
|
||||
}
|
||||
if args.MaxResults < 1 || args.MaxResults > maxSearchResults {
|
||||
return args, fmt.Errorf("max_results must be between 1 and %d", maxSearchResults)
|
||||
}
|
||||
args.Topic = strings.ToLower(strings.TrimSpace(args.Topic))
|
||||
if args.Topic == "" {
|
||||
args.Topic = "general"
|
||||
}
|
||||
switch args.Topic {
|
||||
case "general", "news", "finance":
|
||||
default:
|
||||
return args, fmt.Errorf("topic must be one of: general, news, finance")
|
||||
}
|
||||
return args, nil
|
||||
}
|
||||
|
||||
func parseWebFetchArgs(raw json.RawMessage) (webFetchArgs, error) {
|
||||
var args webFetchArgs
|
||||
if err := json.Unmarshal(raw, &args); err != nil {
|
||||
return args, fmt.Errorf("parse arguments: %w", err)
|
||||
}
|
||||
args.URL = strings.TrimSpace(args.URL)
|
||||
if args.URL == "" {
|
||||
return args, errors.New("url is required")
|
||||
}
|
||||
if !strings.Contains(args.URL, "://") {
|
||||
args.URL = "https://" + args.URL
|
||||
}
|
||||
parsed, err := url.Parse(args.URL)
|
||||
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" {
|
||||
return args, errors.New("url must be a valid http or https URL")
|
||||
}
|
||||
args.Query = strings.TrimSpace(args.Query)
|
||||
return args, nil
|
||||
}
|
||||
|
||||
func formatWebSearchResults(query string, answer string, results []webSearchResult) string {
|
||||
if len(results) == 0 {
|
||||
return fmt.Sprintf("No web search results for %q.", query)
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "Web search results for %q:\n", query)
|
||||
if strings.TrimSpace(answer) != "" {
|
||||
fmt.Fprintf(&b, "\nSummary: %s\n", strings.TrimSpace(answer))
|
||||
}
|
||||
for i, result := range results {
|
||||
title := strings.TrimSpace(result.Title)
|
||||
if title == "" {
|
||||
title = "Untitled"
|
||||
}
|
||||
fmt.Fprintf(&b, "\n%d. %s\n", i+1, title)
|
||||
fmt.Fprintf(&b, " Source: %s\n", strings.TrimSpace(result.URL))
|
||||
if content := strings.TrimSpace(result.Content); content != "" {
|
||||
fmt.Fprintf(&b, " Snippet: %s\n", content)
|
||||
}
|
||||
if result.Score > 0 {
|
||||
fmt.Fprintf(&b, " Score: %.3f\n", result.Score)
|
||||
}
|
||||
}
|
||||
return strings.TrimRight(b.String(), "\n")
|
||||
}
|
||||
|
||||
func formatWebFetchResults(inputURL string, response webFetchResponse) string {
|
||||
var b strings.Builder
|
||||
if len(response.Results) == 0 {
|
||||
fmt.Fprintf(&b, "No readable web content extracted from %s.", inputURL)
|
||||
} else {
|
||||
fmt.Fprintf(&b, "Fetched web content:\n")
|
||||
for i, result := range response.Results {
|
||||
sourceURL := strings.TrimSpace(result.URL)
|
||||
if sourceURL == "" {
|
||||
sourceURL = inputURL
|
||||
}
|
||||
fmt.Fprintf(&b, "\n%d. Source: %s\n", i+1, sourceURL)
|
||||
if content := strings.TrimSpace(result.RawContent); content != "" {
|
||||
fmt.Fprintf(&b, "\n%s\n", content)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, failed := range response.FailedResults {
|
||||
sourceURL := strings.TrimSpace(failed.URL)
|
||||
if sourceURL == "" {
|
||||
sourceURL = inputURL
|
||||
}
|
||||
if strings.TrimSpace(failed.Error) != "" {
|
||||
fmt.Fprintf(&b, "\nFetch failed for %s: %s", sourceURL, strings.TrimSpace(failed.Error))
|
||||
}
|
||||
}
|
||||
return strings.TrimRight(b.String(), "\n")
|
||||
}
|
||||
|
||||
func parseSearchResults(r io.Reader, maxResults int) ([]webSearchResult, error) {
|
||||
doc, err := html.Parse(r)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse search results: %w", err)
|
||||
}
|
||||
|
||||
results := make([]webSearchResult, 0, maxResults)
|
||||
var walk func(*html.Node)
|
||||
walk = func(n *html.Node) {
|
||||
if len(results) >= maxResults || n == nil {
|
||||
return
|
||||
}
|
||||
if n.Type == html.ElementNode && n.Data == "div" && hasClass(n, "result") {
|
||||
if result, ok := parseResultNode(n); ok {
|
||||
results = append(results, result)
|
||||
}
|
||||
return
|
||||
}
|
||||
for child := n.FirstChild; child != nil; child = child.NextSibling {
|
||||
walk(child)
|
||||
}
|
||||
}
|
||||
walk(doc)
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func parseResultNode(n *html.Node) (webSearchResult, bool) {
|
||||
var result webSearchResult
|
||||
var walk func(*html.Node)
|
||||
walk = func(node *html.Node) {
|
||||
if node == nil {
|
||||
return
|
||||
}
|
||||
if node.Type == html.ElementNode && node.Data == "a" {
|
||||
if hasClass(node, "result__a") && result.URL == "" {
|
||||
result.Title = cleanWebText(textContent(node))
|
||||
result.URL = decodeSearchURL(attr(node, "href"))
|
||||
}
|
||||
if hasClass(node, "result__snippet") && result.Content == "" {
|
||||
result.Content = cleanWebText(textContent(node))
|
||||
}
|
||||
}
|
||||
if node.Type == html.ElementNode && (node.Data == "div" || node.Data == "span") && hasClass(node, "result__snippet") && result.Content == "" {
|
||||
result.Content = cleanWebText(textContent(node))
|
||||
}
|
||||
for child := node.FirstChild; child != nil; child = child.NextSibling {
|
||||
walk(child)
|
||||
}
|
||||
}
|
||||
walk(n)
|
||||
return result, result.Title != "" && result.URL != ""
|
||||
}
|
||||
|
||||
func decodeSearchURL(raw string) string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
parsed, err := url.Parse(raw)
|
||||
if err == nil {
|
||||
if target := parsed.Query().Get("uddg"); target != "" {
|
||||
if decoded, err := url.QueryUnescape(target); err == nil {
|
||||
return decoded
|
||||
}
|
||||
return target
|
||||
}
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
func htmlToMarkdown(input string, sourceURL string) (string, error) {
|
||||
doc, err := html.Parse(strings.NewReader(input))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("parse html: %w", err)
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
var title string
|
||||
var walk func(*html.Node, int)
|
||||
walk = func(n *html.Node, listDepth int) {
|
||||
if n == nil || skipHTMLNode(n) {
|
||||
return
|
||||
}
|
||||
if n.Type == html.ElementNode && n.Data == "title" && title == "" {
|
||||
title = cleanWebText(textContent(n))
|
||||
return
|
||||
}
|
||||
if n.Type == html.TextNode {
|
||||
writeInlineText(&b, n.Data)
|
||||
return
|
||||
}
|
||||
if n.Type != html.ElementNode {
|
||||
for child := n.FirstChild; child != nil; child = child.NextSibling {
|
||||
walk(child, listDepth)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
switch n.Data {
|
||||
case "h1", "h2", "h3", "h4", "h5", "h6":
|
||||
writeBlockBreak(&b)
|
||||
level := int(n.Data[1] - '0')
|
||||
b.WriteString(strings.Repeat("#", level))
|
||||
b.WriteString(" ")
|
||||
writeChildren(&b, n, listDepth, walk)
|
||||
writeBlockBreak(&b)
|
||||
case "p", "div", "section", "article", "header", "footer", "main", "tr":
|
||||
writeBlockBreak(&b)
|
||||
writeChildren(&b, n, listDepth, walk)
|
||||
writeBlockBreak(&b)
|
||||
case "br":
|
||||
b.WriteString("\n")
|
||||
case "li":
|
||||
writeBlockBreak(&b)
|
||||
b.WriteString(strings.Repeat(" ", listDepth))
|
||||
b.WriteString("- ")
|
||||
writeChildren(&b, n, listDepth+1, walk)
|
||||
writeBlockBreak(&b)
|
||||
case "a":
|
||||
label := cleanWebText(textContent(n))
|
||||
href := strings.TrimSpace(attr(n, "href"))
|
||||
if href != "" && label != "" {
|
||||
href = resolveURL(sourceURL, href)
|
||||
b.WriteString(label)
|
||||
b.WriteString(" (")
|
||||
b.WriteString(href)
|
||||
b.WriteString(")")
|
||||
return
|
||||
}
|
||||
writeChildren(&b, n, listDepth, walk)
|
||||
default:
|
||||
writeChildren(&b, n, listDepth, walk)
|
||||
}
|
||||
}
|
||||
walk(doc, 0)
|
||||
|
||||
content := normalizeMarkdownText(b.String())
|
||||
if title != "" && !strings.Contains(content, title) {
|
||||
content = "# " + title + "\n\n" + content
|
||||
}
|
||||
if strings.TrimSpace(content) == "" {
|
||||
return "", errors.New("no readable content found")
|
||||
}
|
||||
return content, nil
|
||||
}
|
||||
|
||||
func writeChildren(b *strings.Builder, n *html.Node, listDepth int, walk func(*html.Node, int)) {
|
||||
for child := n.FirstChild; child != nil; child = child.NextSibling {
|
||||
walk(child, listDepth)
|
||||
}
|
||||
}
|
||||
|
||||
func writeInlineText(b *strings.Builder, text string) {
|
||||
text = cleanWebText(text)
|
||||
if text == "" {
|
||||
return
|
||||
}
|
||||
if b.Len() > 0 {
|
||||
last := b.String()[b.Len()-1]
|
||||
if last != '\n' && last != ' ' {
|
||||
b.WriteByte(' ')
|
||||
}
|
||||
}
|
||||
b.WriteString(text)
|
||||
}
|
||||
|
||||
func writeBlockBreak(b *strings.Builder) {
|
||||
if b.Len() == 0 {
|
||||
return
|
||||
}
|
||||
text := b.String()
|
||||
if strings.HasSuffix(text, "\n\n") {
|
||||
return
|
||||
}
|
||||
if strings.HasSuffix(text, "\n") {
|
||||
b.WriteByte('\n')
|
||||
return
|
||||
}
|
||||
b.WriteString("\n\n")
|
||||
}
|
||||
|
||||
func skipHTMLNode(n *html.Node) bool {
|
||||
if n.Type != html.ElementNode {
|
||||
return false
|
||||
}
|
||||
switch n.Data {
|
||||
case "script", "style", "noscript", "svg", "canvas", "template":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func textContent(n *html.Node) string {
|
||||
var b strings.Builder
|
||||
var walk func(*html.Node)
|
||||
walk = func(node *html.Node) {
|
||||
if node == nil || skipHTMLNode(node) {
|
||||
return
|
||||
}
|
||||
if node.Type == html.TextNode {
|
||||
b.WriteString(node.Data)
|
||||
b.WriteByte(' ')
|
||||
return
|
||||
}
|
||||
for child := node.FirstChild; child != nil; child = child.NextSibling {
|
||||
walk(child)
|
||||
}
|
||||
}
|
||||
walk(n)
|
||||
return cleanWebText(b.String())
|
||||
}
|
||||
|
||||
func hasClass(n *html.Node, class string) bool {
|
||||
for _, item := range strings.Fields(attr(n, "class")) {
|
||||
if item == class {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func attr(n *html.Node, name string) string {
|
||||
for _, item := range n.Attr {
|
||||
if item.Key == name {
|
||||
return item.Val
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func resolveURL(baseURL, raw string) string {
|
||||
ref, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return raw
|
||||
}
|
||||
base, err := url.Parse(baseURL)
|
||||
if err != nil {
|
||||
return raw
|
||||
}
|
||||
return base.ResolveReference(ref).String()
|
||||
}
|
||||
|
||||
func cleanWebText(text string) string {
|
||||
return strings.Join(strings.Fields(text), " ")
|
||||
}
|
||||
|
||||
func normalizeMarkdownText(text string) string {
|
||||
lines := strings.Split(text, "\n")
|
||||
out := make([]string, 0, len(lines))
|
||||
blank := false
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
if !blank && len(out) > 0 {
|
||||
out = append(out, "")
|
||||
}
|
||||
blank = true
|
||||
continue
|
||||
}
|
||||
out = append(out, line)
|
||||
blank = false
|
||||
}
|
||||
return strings.TrimSpace(strings.Join(out, "\n"))
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBuiltinSearchTool(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
t.Fatalf("method = %s", r.Method)
|
||||
}
|
||||
if got := r.URL.Query().Get("q"); got != "agentu" {
|
||||
t.Fatalf("query = %q", got)
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
_, _ = w.Write([]byte(`
|
||||
<html><body>
|
||||
<div class="result">
|
||||
<a class="result__a" href="/l/?uddg=https%3A%2F%2Fexample.com%2Fagentu">Agentu release</a>
|
||||
<a class="result__snippet">Agentu adds built-in web tools.</a>
|
||||
</div>
|
||||
</body></html>`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
tool := NewBuiltinSearchToolWithBaseURL(server.URL, 5, server.Client())
|
||||
out, err := tool.Execute(context.Background(), json.RawMessage(`{"query":"agentu"}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, want := range []string{"Web search results", "Agentu release", "https://example.com/agentu", "Agentu adds built-in web tools"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("output missing %q:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuiltinFetchTool(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
t.Fatalf("method = %s", r.Method)
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_, _ = w.Write([]byte(`
|
||||
<html>
|
||||
<head><title>Agentu</title><script>hidden()</script></head>
|
||||
<body>
|
||||
<h1>Agentu</h1>
|
||||
<p>Built-in fetch reads HTML.</p>
|
||||
<ul><li>Search</li><li>Fetch</li></ul>
|
||||
</body>
|
||||
</html>`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
tool := NewBuiltinFetchTool(server.Client())
|
||||
out, err := tool.Execute(context.Background(), json.RawMessage(`{"url":"`+server.URL+`"}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, want := range []string{"Fetched web content", server.URL, "# Agentu", "Built-in fetch reads HTML", "- Search", "- Fetch"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("output missing %q:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
if strings.Contains(out, "hidden") {
|
||||
t.Fatalf("script content should be ignored:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuiltinFetchToolAcceptsURLsWithoutScheme(t *testing.T) {
|
||||
args, err := parseWebFetchArgs(json.RawMessage(`{"url":"example.com/page"}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if args.URL != "https://example.com/page" {
|
||||
t.Fatalf("url = %q", args.URL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuiltinFetchToolFormatsFailedResults(t *testing.T) {
|
||||
response := webFetchResponse{
|
||||
FailedResults: []webFetchFailed{{URL: "https://example.com", Error: "blocked"}},
|
||||
}
|
||||
out := formatWebFetchResults("https://example.com", response)
|
||||
for _, want := range []string{"No readable web content", "Fetch failed", "blocked"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("output missing %q:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
+115
-37
@@ -10,6 +10,7 @@ import (
|
||||
|
||||
"agentu/internal/agent"
|
||||
|
||||
"github.com/charmbracelet/bubbles/spinner"
|
||||
"github.com/charmbracelet/bubbles/textarea"
|
||||
"github.com/charmbracelet/bubbles/viewport"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
@@ -70,6 +71,7 @@ type model struct {
|
||||
|
||||
viewport viewport.Model
|
||||
input textarea.Model
|
||||
spinner spinner.Model
|
||||
|
||||
width int
|
||||
height int
|
||||
@@ -118,6 +120,10 @@ func newModel(ctx context.Context, assistant *agent.Agent, opts Options) model {
|
||||
applyTextareaTheme(&input, th)
|
||||
input.Focus()
|
||||
|
||||
spin := spinner.New(
|
||||
spinner.WithSpinner(spinner.Line),
|
||||
)
|
||||
|
||||
vp := viewport.New(80, 20)
|
||||
vp.Style = st.Viewport
|
||||
vp.SetContent("")
|
||||
@@ -132,10 +138,8 @@ func newModel(ctx context.Context, assistant *agent.Agent, opts Options) model {
|
||||
styles: st,
|
||||
viewport: vp,
|
||||
input: input,
|
||||
spinner: spin,
|
||||
status: "ready",
|
||||
messages: []message{
|
||||
{role: roleSystem, content: "Ask anything. Enter sends, ctrl+j or alt+enter inserts a newline, esc cancels a running turn."},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,6 +158,14 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.refreshViewport(true)
|
||||
return m, nil
|
||||
|
||||
case spinner.TickMsg:
|
||||
if !m.running {
|
||||
return m, nil
|
||||
}
|
||||
var cmd tea.Cmd
|
||||
m.spinner, cmd = m.spinner.Update(msg)
|
||||
return m, cmd
|
||||
|
||||
case tea.KeyMsg:
|
||||
switch msg.String() {
|
||||
case "ctrl+c":
|
||||
@@ -201,6 +213,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
} else {
|
||||
m.status = "ready"
|
||||
}
|
||||
m.resize()
|
||||
m.refreshViewport(true)
|
||||
cmds = append(cmds, textarea.Blink)
|
||||
}
|
||||
@@ -239,10 +252,12 @@ func (m model) View() string {
|
||||
return "agentu"
|
||||
}
|
||||
|
||||
header := m.headerView()
|
||||
status := m.statusView()
|
||||
input := m.inputView()
|
||||
body := lipgloss.JoinVertical(lipgloss.Left, header, m.viewport.View(), input, status)
|
||||
parts := []string{m.headerView(), m.viewport.View()}
|
||||
if activity := m.activityView(); activity != "" {
|
||||
parts = append(parts, activity)
|
||||
}
|
||||
parts = append(parts, m.inputView(), m.modelMetaView(), m.footerView())
|
||||
body := lipgloss.JoinVertical(lipgloss.Left, parts...)
|
||||
return m.styles.App.Width(m.width).Height(m.height).Render(body)
|
||||
}
|
||||
|
||||
@@ -276,6 +291,7 @@ func (m *model) submit() (tea.Model, tea.Cmd) {
|
||||
m.afterInputChanged()
|
||||
m.running = true
|
||||
m.status = "thinking..."
|
||||
m.resize()
|
||||
m.refreshViewport(true)
|
||||
|
||||
turnCtx, cancel := context.WithCancel(m.ctx)
|
||||
@@ -301,7 +317,7 @@ func (m *model) submit() (tea.Model, tea.Cmd) {
|
||||
return turnStartedMsg{}
|
||||
}
|
||||
|
||||
return *m, tea.Batch(start, m.waitForEvent())
|
||||
return *m, tea.Batch(start, m.waitForEvent(), m.spinner.Tick)
|
||||
}
|
||||
|
||||
func (m *model) insertInputNewline() (tea.Model, tea.Cmd) {
|
||||
@@ -343,10 +359,14 @@ func (m *model) resize() {
|
||||
m.syncInputHeight()
|
||||
|
||||
headerHeight := 1
|
||||
statusHeight := 1
|
||||
activityHeight := 0
|
||||
if m.running {
|
||||
activityHeight = 1
|
||||
}
|
||||
inputHeight := m.inputBlockHeight()
|
||||
footerHeight := 2
|
||||
m.viewport.Width = width
|
||||
m.viewport.Height = max(4, m.height-headerHeight-statusHeight-inputHeight)
|
||||
m.viewport.Height = max(4, m.height-headerHeight-activityHeight-inputHeight-footerHeight)
|
||||
m.viewport.Style = m.styles.Viewport
|
||||
}
|
||||
|
||||
@@ -369,80 +389,115 @@ func (m model) renderMessages() string {
|
||||
parts = append(parts, m.messageView(msg.role, content, contentWidth))
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
return m.styles.Muted.Width(contentWidth).Render("Start a conversation.")
|
||||
return ""
|
||||
}
|
||||
return strings.Join(parts, "\n\n")
|
||||
}
|
||||
|
||||
func (m model) messageView(r role, content string, width int) string {
|
||||
label := string(r)
|
||||
label := roleLabel(r)
|
||||
style := m.styles.AssistantMsg
|
||||
labelStyle := m.styles.AssistantLabel
|
||||
|
||||
switch r {
|
||||
case roleUser:
|
||||
label = "you"
|
||||
style = m.styles.UserMessage
|
||||
labelStyle = m.styles.UserLabel
|
||||
case roleTool:
|
||||
label = "tool"
|
||||
style = m.styles.ToolMessage
|
||||
labelStyle = m.styles.ToolLabel
|
||||
case roleError:
|
||||
label = "error"
|
||||
style = m.styles.ErrorMessage
|
||||
labelStyle = m.styles.ErrorLabel
|
||||
case roleSystem:
|
||||
label = "note"
|
||||
style = m.styles.SystemMessage
|
||||
labelStyle = m.styles.SystemLabel
|
||||
default:
|
||||
label = "agentu"
|
||||
}
|
||||
|
||||
bodyContent := content
|
||||
if r == roleAssistant {
|
||||
bodyWidth := max(minMarkdownWidth, width-style.GetHorizontalFrameSize())
|
||||
bodyContent = renderMarkdown(content, bodyWidth, m.theme)
|
||||
}
|
||||
|
||||
labelLine := labelStyle.Render(label)
|
||||
body := style.Width(width).Render(content)
|
||||
body := style.Width(width).Render(bodyContent)
|
||||
return lipgloss.JoinVertical(lipgloss.Left, labelLine, body)
|
||||
}
|
||||
|
||||
func roleLabel(r role) string {
|
||||
switch r {
|
||||
case roleUser:
|
||||
return "You"
|
||||
case roleAssistant:
|
||||
return "Agentu"
|
||||
case roleTool:
|
||||
return "Tool"
|
||||
case roleError:
|
||||
return "Error"
|
||||
case roleSystem:
|
||||
return "Note"
|
||||
default:
|
||||
return string(r)
|
||||
}
|
||||
}
|
||||
|
||||
func (m model) headerView() string {
|
||||
title := m.styles.Title.Render("agentu")
|
||||
meta := m.styles.Muted.Render("local agent")
|
||||
line := lipgloss.JoinHorizontal(lipgloss.Center, title, " ", meta)
|
||||
return m.styles.Header.Width(max(1, m.width)).Render(line)
|
||||
}
|
||||
|
||||
func (m model) modelMetaView() string {
|
||||
mode := "read-only tools"
|
||||
if m.yolo {
|
||||
mode = "yolo tools"
|
||||
}
|
||||
modelName := m.modelName
|
||||
thinking := ""
|
||||
parts := []string{}
|
||||
if m.models != nil {
|
||||
if provider := m.models.CurrentProvider(); provider != "" {
|
||||
parts = append(parts, "provider "+provider)
|
||||
}
|
||||
modelName = m.models.CurrentModel()
|
||||
if currentThinking := m.models.CurrentThinking(); currentThinking != "" && currentThinking != "unset" {
|
||||
thinking = " · thinking " + currentThinking
|
||||
parts = append(parts, "thinking "+currentThinking)
|
||||
}
|
||||
}
|
||||
title := m.styles.Title.Render("agentu")
|
||||
meta := m.styles.Muted.Render(fmt.Sprintf("%s · %s%s · %s", modelName, mode, thinking, m.theme.Mode))
|
||||
line := lipgloss.JoinHorizontal(lipgloss.Center, title, " ", meta)
|
||||
return m.styles.Header.Width(max(1, m.width)).Render(line)
|
||||
parts = append([]string{modelName, mode}, parts...)
|
||||
parts = append(parts, "theme "+string(m.theme.Mode))
|
||||
line := fitLine(strings.Join(parts, " · "), max(1, m.width-2))
|
||||
return m.styles.ModelMeta.Width(max(1, m.width)).Render(line)
|
||||
}
|
||||
|
||||
func (m model) inputView() string {
|
||||
title := m.styles.Muted.Render("Enter sends · ctrl+j newline · alt+enter newline · /clear · /exit")
|
||||
if m.running {
|
||||
title = m.styles.Muted.Render("Working · esc cancel · ctrl+c quit")
|
||||
}
|
||||
suggestions := m.slashSuggestionsView()
|
||||
box := m.styles.InputBox.Width(max(20, m.width-2)).Render(m.input.View())
|
||||
if suggestions != "" {
|
||||
return lipgloss.JoinVertical(lipgloss.Left, suggestions, title, box)
|
||||
return lipgloss.JoinVertical(lipgloss.Left, suggestions, box)
|
||||
}
|
||||
return lipgloss.JoinVertical(lipgloss.Left, title, box)
|
||||
return box
|
||||
}
|
||||
|
||||
func (m model) statusView() string {
|
||||
left := m.status
|
||||
if left == "" {
|
||||
left = "ready"
|
||||
func (m model) activityView() string {
|
||||
if !m.running {
|
||||
return ""
|
||||
}
|
||||
return m.styles.Status.Width(max(1, m.width)).Render(left)
|
||||
status := strings.TrimSpace(m.status)
|
||||
if status == "" || status == "ready" {
|
||||
status = "working..."
|
||||
}
|
||||
line := fitLine(fmt.Sprintf("%s Working · %s · esc cancels", m.spinner.View(), status), max(1, m.width-2))
|
||||
return m.styles.Activity.Width(max(1, m.width)).Render(line)
|
||||
}
|
||||
|
||||
func (m model) footerView() string {
|
||||
hint := "enter send · ctrl+j newline · alt+enter newline · / commands"
|
||||
if m.running {
|
||||
hint = "esc cancel · ctrl+c quit"
|
||||
}
|
||||
return m.styles.Footer.Width(max(1, m.width)).Render(fitLine(hint, max(1, m.width-2)))
|
||||
}
|
||||
|
||||
type eventWriter struct {
|
||||
@@ -514,7 +569,7 @@ func (m model) desiredInputHeight() int {
|
||||
}
|
||||
|
||||
func (m model) inputBlockHeight() int {
|
||||
height := 1 + m.input.Height() + m.styles.InputBox.GetVerticalFrameSize()
|
||||
height := m.input.Height() + m.styles.InputBox.GetVerticalFrameSize()
|
||||
if m.showSlashSuggestions() {
|
||||
height++
|
||||
}
|
||||
@@ -555,6 +610,29 @@ func (m model) modelInfo() string {
|
||||
return fmt.Sprintf("model: %s\nmode: %s\ntheme: %s", m.modelName, mode, m.theme.Mode)
|
||||
}
|
||||
|
||||
func fitLine(text string, width int) string {
|
||||
width = max(1, width)
|
||||
if lipgloss.Width(text) <= width {
|
||||
return text
|
||||
}
|
||||
if width <= 3 {
|
||||
return strings.Repeat(".", width)
|
||||
}
|
||||
|
||||
target := width - 3
|
||||
used := 0
|
||||
var b strings.Builder
|
||||
for _, r := range text {
|
||||
next := lipgloss.Width(string(r))
|
||||
if used+next > target {
|
||||
break
|
||||
}
|
||||
b.WriteRune(r)
|
||||
used += next
|
||||
}
|
||||
return b.String() + "..."
|
||||
}
|
||||
|
||||
func (m *model) runLocalCommand(input string) (tea.Model, tea.Cmd) {
|
||||
output, err := m.handleLocalCommand(input)
|
||||
role := roleSystem
|
||||
|
||||
@@ -20,6 +20,109 @@ func TestModelRendersChatShell(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelMetaRendersBelowInput(t *testing.T) {
|
||||
m := newModel(context.Background(), nil, Options{ModelName: "test-model", Yolo: true})
|
||||
next, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 30})
|
||||
rendered := next.(model).View()
|
||||
|
||||
inputIndex := strings.Index(rendered, "Message agentu")
|
||||
modelIndex := strings.Index(rendered, "test-model")
|
||||
if inputIndex < 0 || modelIndex < 0 {
|
||||
t.Fatalf("rendered view missing input or model meta:\n%s", rendered)
|
||||
}
|
||||
if modelIndex < inputIndex {
|
||||
t.Fatalf("model meta should render below input:\n%s", rendered)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitialViewDoesNotRenderTopNote(t *testing.T) {
|
||||
m := newModel(context.Background(), nil, Options{ModelName: "test-model"})
|
||||
next, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 30})
|
||||
rendered := next.(model).View()
|
||||
|
||||
for _, unwanted := range []string{"Note", "Ask anything", "Start a conversation"} {
|
||||
if strings.Contains(rendered, unwanted) {
|
||||
t.Fatalf("initial view should not render %q:\n%s", unwanted, rendered)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunningStateRendersActivityIndicator(t *testing.T) {
|
||||
m := newModel(context.Background(), nil, Options{ModelName: "test-model"})
|
||||
next, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 30})
|
||||
m = next.(model)
|
||||
m.running = true
|
||||
m.status = "thinking..."
|
||||
m.resize()
|
||||
|
||||
rendered := m.View()
|
||||
for _, want := range []string{"Working", "thinking...", "esc cancels"} {
|
||||
if !strings.Contains(rendered, want) {
|
||||
t.Fatalf("running view missing %q:\n%s", want, rendered)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserMessageRendersOnLeft(t *testing.T) {
|
||||
m := newModel(context.Background(), nil, Options{ModelName: "test-model"})
|
||||
next, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 30})
|
||||
m = next.(model)
|
||||
|
||||
rendered := m.messageView(roleUser, "hello", 80)
|
||||
if strings.HasPrefix(rendered, " ") {
|
||||
t.Fatalf("user message should not be right-aligned:\n%q", rendered)
|
||||
}
|
||||
if !strings.Contains(rendered, "You") || !strings.Contains(rendered, "hello") {
|
||||
t.Fatalf("user message missing content:\n%s", rendered)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssistantMessagesRenderMarkdown(t *testing.T) {
|
||||
m := newModel(context.Background(), nil, Options{ModelName: "test-model"})
|
||||
next, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 30})
|
||||
m = next.(model)
|
||||
|
||||
rendered := m.messageView(roleAssistant, "# Title\n\n- item", 80)
|
||||
for _, want := range []string{"Agentu", "Title", "item"} {
|
||||
if !strings.Contains(rendered, want) {
|
||||
t.Fatalf("assistant markdown missing %q:\n%s", want, rendered)
|
||||
}
|
||||
}
|
||||
if strings.Contains(rendered, "# Title") {
|
||||
t.Fatalf("assistant markdown heading was not rendered:\n%s", rendered)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserMessagesDoNotRenderMarkdown(t *testing.T) {
|
||||
m := newModel(context.Background(), nil, Options{ModelName: "test-model"})
|
||||
next, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 30})
|
||||
m = next.(model)
|
||||
|
||||
rendered := m.messageView(roleUser, "# Title\n\n- item", 80)
|
||||
for _, want := range []string{"# Title", "- item"} {
|
||||
if !strings.Contains(rendered, want) {
|
||||
t.Fatalf("user markdown should stay plain and include %q:\n%s", want, rendered)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageRoleLabels(t *testing.T) {
|
||||
m := newModel(context.Background(), nil, Options{ModelName: "test-model"})
|
||||
next, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 30})
|
||||
m = next.(model)
|
||||
m.messages = []message{
|
||||
{role: roleUser, content: "hello"},
|
||||
{role: roleAssistant, content: "hi"},
|
||||
}
|
||||
|
||||
rendered := m.renderMessages()
|
||||
for _, want := range []string{"You", "Agentu"} {
|
||||
if !strings.Contains(rendered, want) {
|
||||
t.Fatalf("messages missing %q:\n%s", want, rendered)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCtrlJInsertsInputNewline(t *testing.T) {
|
||||
m := newModel(context.Background(), nil, Options{ModelName: "test-model"})
|
||||
m.input.SetValue("hello")
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/glamour"
|
||||
"github.com/charmbracelet/glamour/ansi"
|
||||
glamstyles "github.com/charmbracelet/glamour/styles"
|
||||
)
|
||||
|
||||
const minMarkdownWidth = 20
|
||||
|
||||
func renderMarkdown(content string, width int, th theme) string {
|
||||
width = max(minMarkdownWidth, width)
|
||||
renderer, err := glamour.NewTermRenderer(
|
||||
glamour.WithStyles(markdownStyle(th)),
|
||||
glamour.WithWordWrap(width),
|
||||
)
|
||||
if err != nil {
|
||||
return content
|
||||
}
|
||||
|
||||
rendered, err := renderer.Render(content)
|
||||
if err != nil {
|
||||
return content
|
||||
}
|
||||
return strings.TrimRight(rendered, "\n")
|
||||
}
|
||||
|
||||
func markdownStyle(th theme) ansi.StyleConfig {
|
||||
cfg := glamstyles.LightStyleConfig
|
||||
if th.Mode == ThemeDark {
|
||||
cfg = glamstyles.DarkStyleConfig
|
||||
}
|
||||
|
||||
zero := uint(0)
|
||||
cfg.Document.Margin = &zero
|
||||
cfg.Document.BlockPrefix = ""
|
||||
cfg.Document.BlockSuffix = ""
|
||||
cfg.Document.Color = stringPtr(th.Text)
|
||||
|
||||
cfg.Heading.Color = stringPtr(th.Assistant)
|
||||
cfg.H1.Prefix = ""
|
||||
cfg.H1.Suffix = ""
|
||||
cfg.H1.Color = stringPtr(th.Assistant)
|
||||
cfg.H1.BackgroundColor = nil
|
||||
cfg.H1.Bold = boolPtr(true)
|
||||
|
||||
cfg.BlockQuote.Color = stringPtr(th.Muted)
|
||||
cfg.Link.Color = stringPtr(th.User)
|
||||
cfg.LinkText.Color = stringPtr(th.User)
|
||||
|
||||
cfg.Code.Color = stringPtr(th.Tool)
|
||||
cfg.Code.BackgroundColor = stringPtr(th.SurfaceAlt)
|
||||
cfg.CodeBlock.Color = stringPtr(th.Text)
|
||||
cfg.CodeBlock.BackgroundColor = nil
|
||||
cfg.CodeBlock.Margin = &zero
|
||||
|
||||
return cfg
|
||||
}
|
||||
|
||||
func stringPtr(value string) *string {
|
||||
return &value
|
||||
}
|
||||
|
||||
func boolPtr(value bool) *bool {
|
||||
return &value
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRenderMarkdownSupportsCommonAssistantContent(t *testing.T) {
|
||||
rendered := renderMarkdown("# Title\n\n- one\n- two\n\n```go\nfmt.Println(\"x\")\n```", 80, themeForMode(ThemeLight))
|
||||
plain := stripANSI(rendered)
|
||||
for _, want := range []string{"Title", "one", "two", `fmt.Println("x")`} {
|
||||
if !strings.Contains(plain, want) {
|
||||
t.Fatalf("markdown output missing %q:\n%s", want, rendered)
|
||||
}
|
||||
}
|
||||
if strings.Contains(plain, "# Title") {
|
||||
t.Fatalf("heading was not rendered:\n%s", rendered)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderMarkdownSupportsThemesAndNarrowWidth(t *testing.T) {
|
||||
for _, mode := range []ThemeMode{ThemeLight, ThemeDark} {
|
||||
rendered := renderMarkdown("**bold** and `code`", 4, themeForMode(mode))
|
||||
plain := stripANSI(rendered)
|
||||
if rendered == "" {
|
||||
t.Fatalf("empty markdown output for %s", mode)
|
||||
}
|
||||
for _, want := range []string{"bold", "code"} {
|
||||
if !strings.Contains(plain, want) {
|
||||
t.Fatalf("markdown output for %s missing %q:\n%s", mode, want, rendered)
|
||||
}
|
||||
}
|
||||
if strings.Contains(plain, "**bold**") {
|
||||
t.Fatalf("strong markdown was not rendered for %s:\n%s", mode, rendered)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var ansiPattern = regexp.MustCompile(`\x1b\[[0-9;:]*[A-Za-z]`)
|
||||
|
||||
func stripANSI(value string) string {
|
||||
return ansiPattern.ReplaceAllString(value, "")
|
||||
}
|
||||
+16
-3
@@ -49,7 +49,9 @@ type styles struct {
|
||||
Header lipgloss.Style
|
||||
Title lipgloss.Style
|
||||
Muted lipgloss.Style
|
||||
Status lipgloss.Style
|
||||
Activity lipgloss.Style
|
||||
ModelMeta lipgloss.Style
|
||||
Footer lipgloss.Style
|
||||
Viewport lipgloss.Style
|
||||
InputBox lipgloss.Style
|
||||
CommandHint lipgloss.Style
|
||||
@@ -126,11 +128,22 @@ func (t theme) styles() styles {
|
||||
Muted: lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color(t.Muted)),
|
||||
|
||||
Status: lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color(t.Muted)).
|
||||
Activity: lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Foreground(lipgloss.Color(t.Background)).
|
||||
Background(lipgloss.Color(t.Title)).
|
||||
Padding(0, 1),
|
||||
|
||||
ModelMeta: lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color(t.Text)).
|
||||
Background(lipgloss.Color(t.SurfaceAlt)).
|
||||
Padding(0, 1),
|
||||
|
||||
Footer: lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color(t.Muted)).
|
||||
Background(lipgloss.Color(t.Background)).
|
||||
Padding(0, 1),
|
||||
|
||||
Viewport: lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color(t.Text)).
|
||||
Background(lipgloss.Color(t.Background)),
|
||||
|
||||
Reference in New Issue
Block a user