2 Commits

Author SHA1 Message Date
loveuer 8dbcb1147f v0.0.3: add built-in web tools
Add no-key web_search and web_fetch tools.
Fetch pages with net/http and parse HTML content.
Document built-in web capabilities.
2026-06-23 09:59:24 +08:00
loveuer 86f5ca4aef v0.0.2: polish tui rendering
Add visible activity state for running turns.
Move model metadata below the input area.
Render assistant Markdown with theme-aware styling.
2026-06-23 09:06:20 +08:00
13 changed files with 1145 additions and 48 deletions
+10
View File
@@ -11,6 +11,7 @@ model/provider switching, and local tools.
- Multiple providers from `~/.agentu/config.yaml`. - Multiple providers from `~/.agentu/config.yaml`.
- Session-only provider, model, and thinking-level switching. - Session-only provider, model, and thinking-level switching.
- Read-only project tools enabled by default. - Read-only project tools enabled by default.
- Built-in web search and URL fetching.
- `--yolo` mode for file writes and shell execution. - `--yolo` mode for file writes and shell execution.
## Quick Start ## Quick Start
@@ -58,6 +59,10 @@ agent:
tool_timeout: 30s 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 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 first provider by name. Use `/model provider <name>` to switch providers for the
current session. current session.
@@ -112,6 +117,11 @@ Read-only tools are available by default:
- `file_list` - `file_list`
- `file_search` - `file_search`
Web tools are also available in read-only mode:
- `web_search`
- `web_fetch`
`--yolo` additionally enables: `--yolo` additionally enables:
- `file_write` - `file_write`
+2
View File
@@ -60,6 +60,8 @@ func run() error {
} else { } else {
registry = tools.ReadOnlyBuiltins(cfg.Agent.WorkingDir) 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)) systemPrompt := strings.TrimSpace(cfg.Agent.SystemPrompt + "\n\n" + tools.AgentInstructions(cfg.Agent.WorkingDir, *yolo))
assistant := agent.New(agent.Options{ assistant := agent.New(agent.Options{
+14 -2
View File
@@ -5,30 +5,42 @@ go 1.26
require ( require (
github.com/charmbracelet/bubbles v1.0.0 github.com/charmbracelet/bubbles v1.0.0
github.com/charmbracelet/bubbletea v1.3.10 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 gopkg.in/yaml.v3 v3.0.1
) )
require ( require (
github.com/alecthomas/chroma/v2 v2.20.0 // indirect
github.com/atotto/clipboard v0.1.4 // indirect github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // 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/colorprofile v0.4.1 // indirect
github.com/charmbracelet/x/ansi v0.11.6 // indirect github.com/charmbracelet/x/ansi v0.11.6 // indirect
github.com/charmbracelet/x/cellbuf v0.0.15 // 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/charmbracelet/x/term v0.2.2 // indirect
github.com/clipperhouse/displaywidth v0.9.0 // indirect github.com/clipperhouse/displaywidth v0.9.0 // indirect
github.com/clipperhouse/stringish v0.1.1 // indirect github.com/clipperhouse/stringish v0.1.1 // indirect
github.com/clipperhouse/uax29/v2 v2.5.0 // 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/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/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.19 // 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/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // 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/muesli/termenv v0.16.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect github.com/rivo/uniseg v0.4.7 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // 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/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
) )
+39 -4
View File
@@ -1,23 +1,37 @@
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= 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 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= 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 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= 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 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= 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 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= 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 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= 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 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= 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/glamour v1.0.0 h1:AWMLOVFHTsysl4WV8T8QgkQ0s/ZNZo7CiE4WKhk8l08=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= 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 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= 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 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= 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 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA= 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/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 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= 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 h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= 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 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 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 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 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 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= 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 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= 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 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= 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 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= 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 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= 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 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 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 h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= 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 h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= 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.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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= 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/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= 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 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+11 -2
View File
@@ -82,6 +82,9 @@ func Builtins(workingDir string) *Registry {
} }
func AgentInstructions(workingDir string, yolo bool) string { 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 { if yolo {
return fmt.Sprintf(`Local project context: return fmt.Sprintf(`Local project context:
- The project working directory is %q. - 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. - 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 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. - 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. - 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: return fmt.Sprintf(`Local project context:
- The project working directory is %q. - 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. - 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. - 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. - 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. - 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 { func JSONSchema(schema string) json.RawMessage {
+30
View File
@@ -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) { func TestBuiltinsExposeYoloTools(t *testing.T) {
registry := Builtins(t.TempDir()) registry := Builtins(t.TempDir())
for _, name := range []string{"file_write", "shell_run", "file.write", "shell.run"} { 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)
}
}
}
+597
View File
@@ -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"))
}
+96
View File
@@ -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
View File
@@ -10,6 +10,7 @@ import (
"agentu/internal/agent" "agentu/internal/agent"
"github.com/charmbracelet/bubbles/spinner"
"github.com/charmbracelet/bubbles/textarea" "github.com/charmbracelet/bubbles/textarea"
"github.com/charmbracelet/bubbles/viewport" "github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
@@ -70,6 +71,7 @@ type model struct {
viewport viewport.Model viewport viewport.Model
input textarea.Model input textarea.Model
spinner spinner.Model
width int width int
height int height int
@@ -118,6 +120,10 @@ func newModel(ctx context.Context, assistant *agent.Agent, opts Options) model {
applyTextareaTheme(&input, th) applyTextareaTheme(&input, th)
input.Focus() input.Focus()
spin := spinner.New(
spinner.WithSpinner(spinner.Line),
)
vp := viewport.New(80, 20) vp := viewport.New(80, 20)
vp.Style = st.Viewport vp.Style = st.Viewport
vp.SetContent("") vp.SetContent("")
@@ -132,10 +138,8 @@ func newModel(ctx context.Context, assistant *agent.Agent, opts Options) model {
styles: st, styles: st,
viewport: vp, viewport: vp,
input: input, input: input,
spinner: spin,
status: "ready", 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) m.refreshViewport(true)
return m, nil 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: case tea.KeyMsg:
switch msg.String() { switch msg.String() {
case "ctrl+c": case "ctrl+c":
@@ -201,6 +213,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} else { } else {
m.status = "ready" m.status = "ready"
} }
m.resize()
m.refreshViewport(true) m.refreshViewport(true)
cmds = append(cmds, textarea.Blink) cmds = append(cmds, textarea.Blink)
} }
@@ -239,10 +252,12 @@ func (m model) View() string {
return "agentu" return "agentu"
} }
header := m.headerView() parts := []string{m.headerView(), m.viewport.View()}
status := m.statusView() if activity := m.activityView(); activity != "" {
input := m.inputView() parts = append(parts, activity)
body := lipgloss.JoinVertical(lipgloss.Left, header, m.viewport.View(), input, status) }
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) 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.afterInputChanged()
m.running = true m.running = true
m.status = "thinking..." m.status = "thinking..."
m.resize()
m.refreshViewport(true) m.refreshViewport(true)
turnCtx, cancel := context.WithCancel(m.ctx) turnCtx, cancel := context.WithCancel(m.ctx)
@@ -301,7 +317,7 @@ func (m *model) submit() (tea.Model, tea.Cmd) {
return turnStartedMsg{} 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) { func (m *model) insertInputNewline() (tea.Model, tea.Cmd) {
@@ -343,10 +359,14 @@ func (m *model) resize() {
m.syncInputHeight() m.syncInputHeight()
headerHeight := 1 headerHeight := 1
statusHeight := 1 activityHeight := 0
if m.running {
activityHeight = 1
}
inputHeight := m.inputBlockHeight() inputHeight := m.inputBlockHeight()
footerHeight := 2
m.viewport.Width = width 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 m.viewport.Style = m.styles.Viewport
} }
@@ -369,80 +389,115 @@ func (m model) renderMessages() string {
parts = append(parts, m.messageView(msg.role, content, contentWidth)) parts = append(parts, m.messageView(msg.role, content, contentWidth))
} }
if len(parts) == 0 { if len(parts) == 0 {
return m.styles.Muted.Width(contentWidth).Render("Start a conversation.") return ""
} }
return strings.Join(parts, "\n\n") return strings.Join(parts, "\n\n")
} }
func (m model) messageView(r role, content string, width int) string { func (m model) messageView(r role, content string, width int) string {
label := string(r) label := roleLabel(r)
style := m.styles.AssistantMsg style := m.styles.AssistantMsg
labelStyle := m.styles.AssistantLabel labelStyle := m.styles.AssistantLabel
switch r { switch r {
case roleUser: case roleUser:
label = "you"
style = m.styles.UserMessage style = m.styles.UserMessage
labelStyle = m.styles.UserLabel labelStyle = m.styles.UserLabel
case roleTool: case roleTool:
label = "tool"
style = m.styles.ToolMessage style = m.styles.ToolMessage
labelStyle = m.styles.ToolLabel labelStyle = m.styles.ToolLabel
case roleError: case roleError:
label = "error"
style = m.styles.ErrorMessage style = m.styles.ErrorMessage
labelStyle = m.styles.ErrorLabel labelStyle = m.styles.ErrorLabel
case roleSystem: case roleSystem:
label = "note"
style = m.styles.SystemMessage style = m.styles.SystemMessage
labelStyle = m.styles.SystemLabel 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) labelLine := labelStyle.Render(label)
body := style.Width(width).Render(content) body := style.Width(width).Render(bodyContent)
return lipgloss.JoinVertical(lipgloss.Left, labelLine, body) 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 { 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" mode := "read-only tools"
if m.yolo { if m.yolo {
mode = "yolo tools" mode = "yolo tools"
} }
modelName := m.modelName modelName := m.modelName
thinking := "" parts := []string{}
if m.models != nil { if m.models != nil {
if provider := m.models.CurrentProvider(); provider != "" {
parts = append(parts, "provider "+provider)
}
modelName = m.models.CurrentModel() modelName = m.models.CurrentModel()
if currentThinking := m.models.CurrentThinking(); currentThinking != "" && currentThinking != "unset" { if currentThinking := m.models.CurrentThinking(); currentThinking != "" && currentThinking != "unset" {
thinking = " · thinking " + currentThinking parts = append(parts, "thinking "+currentThinking)
} }
} }
title := m.styles.Title.Render("agentu") parts = append([]string{modelName, mode}, parts...)
meta := m.styles.Muted.Render(fmt.Sprintf("%s · %s%s · %s", modelName, mode, thinking, m.theme.Mode)) parts = append(parts, "theme "+string(m.theme.Mode))
line := lipgloss.JoinHorizontal(lipgloss.Center, title, " ", meta) line := fitLine(strings.Join(parts, " · "), max(1, m.width-2))
return m.styles.Header.Width(max(1, m.width)).Render(line) return m.styles.ModelMeta.Width(max(1, m.width)).Render(line)
} }
func (m model) inputView() string { 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() suggestions := m.slashSuggestionsView()
box := m.styles.InputBox.Width(max(20, m.width-2)).Render(m.input.View()) box := m.styles.InputBox.Width(max(20, m.width-2)).Render(m.input.View())
if suggestions != "" { 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 { func (m model) activityView() string {
left := m.status if !m.running {
if left == "" { return ""
left = "ready"
} }
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 { type eventWriter struct {
@@ -514,7 +569,7 @@ func (m model) desiredInputHeight() int {
} }
func (m model) inputBlockHeight() 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() { if m.showSlashSuggestions() {
height++ 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) 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) { func (m *model) runLocalCommand(input string) (tea.Model, tea.Cmd) {
output, err := m.handleLocalCommand(input) output, err := m.handleLocalCommand(input)
role := roleSystem role := roleSystem
+103
View File
@@ -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) { func TestCtrlJInsertsInputNewline(t *testing.T) {
m := newModel(context.Background(), nil, Options{ModelName: "test-model"}) m := newModel(context.Background(), nil, Options{ModelName: "test-model"})
m.input.SetValue("hello") m.input.SetValue("hello")
+68
View File
@@ -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
}
+44
View File
@@ -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
View File
@@ -49,7 +49,9 @@ type styles struct {
Header lipgloss.Style Header lipgloss.Style
Title lipgloss.Style Title lipgloss.Style
Muted lipgloss.Style Muted lipgloss.Style
Status lipgloss.Style Activity lipgloss.Style
ModelMeta lipgloss.Style
Footer lipgloss.Style
Viewport lipgloss.Style Viewport lipgloss.Style
InputBox lipgloss.Style InputBox lipgloss.Style
CommandHint lipgloss.Style CommandHint lipgloss.Style
@@ -126,11 +128,22 @@ func (t theme) styles() styles {
Muted: lipgloss.NewStyle(). Muted: lipgloss.NewStyle().
Foreground(lipgloss.Color(t.Muted)), Foreground(lipgloss.Color(t.Muted)),
Status: lipgloss.NewStyle(). Activity: lipgloss.NewStyle().
Foreground(lipgloss.Color(t.Muted)). 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)). Background(lipgloss.Color(t.SurfaceAlt)).
Padding(0, 1), Padding(0, 1),
Footer: lipgloss.NewStyle().
Foreground(lipgloss.Color(t.Muted)).
Background(lipgloss.Color(t.Background)).
Padding(0, 1),
Viewport: lipgloss.NewStyle(). Viewport: lipgloss.NewStyle().
Foreground(lipgloss.Color(t.Text)). Foreground(lipgloss.Color(t.Text)).
Background(lipgloss.Color(t.Background)), Background(lipgloss.Color(t.Background)),