From 64fa85efbe772666434b8da0967689b4264a3f17 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 18 Jun 2026 07:55:58 +0000 Subject: [PATCH 1/5] feat: Bubble Tea TUI for the odek agent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bodek is a beautiful terminal front-end for odek. Rather than re-implementing any agent logic, it launches (or attaches to) an `odek serve` instance and renders its streaming WebSocket protocol — reusing odek's tools, danger approval engine, sandbox, skills, memory, and sessions verbatim. - internal/server: spawn/attach to `odek serve`, resolve the CSRF token via GET / Set-Cookie, supervise the subprocess lifecycle - internal/client: odek serve WebSocket protocol (x/net/websocket) — transport plus decoding of every event (token, thinking, tool_call, tool_result, done, error, approval_request, skill/memory/agent events) - internal/tui: Bubble Tea model with live token streaming, per-tool activity, inline danger approvals (a/d/t), glamour-rendered Markdown, gradient wordmark, and session token/latency telemetry - cmd/bodek: CLI entry, flags (--url, --odek-bin, --sandbox), lifecycle Verified end-to-end against a locally built odek serve: connect, token auth, WebSocket dial, prompt, and event decoding all work. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_012rWqu7pktbd2ejfNw3a7Vf --- .gitignore | 9 + LICENSE | 21 ++ Makefile | 36 +++ README.md | 132 ++++++++++ go.mod | 45 ++++ go.sum | 91 +++++++ internal/client/client.go | 149 +++++++++++ internal/client/client_test.go | 81 ++++++ internal/server/server.go | 177 +++++++++++++ internal/tui/banner.go | 57 +++++ internal/tui/helpers_test.go | 50 ++++ internal/tui/messages.go | 25 ++ internal/tui/model.go | 455 +++++++++++++++++++++++++++++++++ internal/tui/styles.go | 145 +++++++++++ internal/tui/view.go | 268 +++++++++++++++++++ 15 files changed, 1741 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 README.md create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/client/client.go create mode 100644 internal/client/client_test.go create mode 100644 internal/server/server.go create mode 100644 internal/tui/banner.go create mode 100644 internal/tui/helpers_test.go create mode 100644 internal/tui/messages.go create mode 100644 internal/tui/model.go create mode 100644 internal/tui/styles.go create mode 100644 internal/tui/view.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ee26f5b --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +# Build artifacts +/bin/ +bodek + +# Editor / OS noise +.DS_Store +*.swp +.idea/ +.vscode/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..640bc60 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 21no.de / BackendStack21 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..9f51fb8 --- /dev/null +++ b/Makefile @@ -0,0 +1,36 @@ +BINARY := bodek +PKG := ./cmd/bodek +GOBIN ?= $(shell go env GOPATH)/bin + +.PHONY: all build install run fmt vet tidy clean + +all: build + +## build: compile the bodek binary into ./bin +build: + @mkdir -p bin + go build -o bin/$(BINARY) $(PKG) + +## install: install bodek into $GOBIN +install: + go install $(PKG) + +## run: build and launch bodek (spawns `odek serve`) +run: build + ./bin/$(BINARY) + +## fmt: format all Go sources +fmt: + go fmt ./... + +## vet: run go vet +vet: + go vet ./... + +## tidy: tidy module dependencies +tidy: + go mod tidy + +## clean: remove build artifacts +clean: + rm -rf bin diff --git a/README.md b/README.md new file mode 100644 index 0000000..d8f61a0 --- /dev/null +++ b/README.md @@ -0,0 +1,132 @@ +# bodek + +**A beautiful [Bubble Tea](https://github.com/charmbracelet/bubbletea) terminal interface for the [odek](https://github.com/BackendStack21/odek) agent.** + +``` +██████ ██████ ██████ ███████ ██ ██ +██ ██ ██ ██ ██ ██ ██ ██ ██ +██████ ██ ██ ██ ██ █████ █████ +██ ██ ██ ██ ██ ██ ██ ██ ██ +██████ ██████ ██████ ███████ ██ ██ +``` + +bodek is a **pure front-end**. It launches (or attaches to) an `odek serve` +instance and renders the agent's live stream — reasoning, tokens, tool calls, +approvals, skills, and memory — as a polished TUI. Every bit of agent +behaviour (tools, danger gating, sandbox, skills, memory, sessions) comes from +**odek itself**; bodek never re-implements any of it. + +--- + +## Why a separate front-end? + +odek already ships a streaming WebSocket protocol (the one its Web UI speaks). +bodek reuses that exact protocol from the terminal, which means: + +- **Zero duplicated logic** — tools, the `danger` approval engine, the Docker + sandbox, skills, and memory all run inside odek, unchanged. +- **Full fidelity** — token streaming, per-tool activity, and security prompts + appear in the TUI exactly as the engine emits them. +- **One source of truth** — upgrade odek and bodek gets the new behaviour for + free. + +``` +┌──────────────┐ WebSocket (RFC 6455, JSON) ┌──────────────────┐ +│ bodek │ ◄────────────────────────────► │ odek serve │ +│ (Bubble Tea) │ tokens · tools · approvals │ (ReAct engine, │ +│ TUI client │ │ tools, sandbox) │ +└──────────────┘ └──────────────────┘ +``` + +--- + +## Install + +```bash +# Install odek (the engine) and bodek (the TUI) +go install github.com/BackendStack21/odek/cmd/odek@latest +go install github.com/BackendStack21/bodek/cmd/bodek@latest + +# Provide an LLM key (any OpenAI-compatible provider) +export ODEK_API_KEY=sk-... + +bodek +``` + +bodek looks for `odek` on your `PATH`. To point at a specific binary use +`--odek-bin`, or skip spawning entirely with `--url`. + +--- + +## Usage + +```bash +bodek # launch odek serve and start chatting +bodek --sandbox # run tool calls inside odek's Docker sandbox +bodek --url http://127.0.0.1:8080 # attach to an already-running odek serve +bodek --odek-bin ./odek # use a specific odek binary +bodek -- --prompt-caching # pass extra flags through to `odek serve` +``` + +Configuration (model, base URL, API key, MCP servers, memory, skills) is read +by `odek serve` from its usual chain — `~/.odek/config.json` → `./odek.json` → +`ODEK_*` env vars — so bodek inherits whatever you've already set up. + +### Key bindings + +| Key | Action | +|-----|--------| +| `⏎` | Send the prompt | +| `^J` | Insert a newline in the input | +| `^T` | Toggle extended thinking for the next turn | +| `^L` | Clear the conversation | +| `PgUp` / `PgDn` / wheel | Scroll the transcript | +| `^C` | Quit | + +When the agent requests approval for a dangerous operation, answer inline: + +| Key | Action | +|-----|--------| +| `a` | Approve once | +| `d` | Deny | +| `t` | Trust this risk class for the session (when offered) | + +--- + +## What you see + +- **Streaming answers** rendered as Markdown ([glamour](https://github.com/charmbracelet/glamour)). +- **Tool activity** — every `tool_call`/`tool_result` shown live with a spinner, + argument preview, and a one-line result. +- **Security approvals** — odek's `danger` engine prompts surface as an inline + panel; your answer is sent straight back over the socket. +- **Live reasoning** — the model's pre-tool thinking streams in dimmed text. +- **Telemetry** — session token totals and last-turn latency in the chrome. +- **Engine notices** — skill loads, memory merges, and agent signals appear as + quiet status lines. + +--- + +## Development + +```bash +make build # → bin/bodek +make run # build and launch +make vet +make tidy +``` + +Project layout: + +| Path | Responsibility | +|------|----------------| +| `cmd/bodek` | CLI entry point: flags, lifecycle, wiring | +| `internal/server` | Launch / attach to `odek serve`, resolve the auth token | +| `internal/client` | odek serve WebSocket protocol (transport + event decoding) | +| `internal/tui` | The Bubble Tea model, update loop, and view | + +--- + +## License + +MIT — see [LICENSE](LICENSE). diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..34bce4e --- /dev/null +++ b/go.mod @@ -0,0 +1,45 @@ +module github.com/BackendStack21/bodek + +go 1.25.0 + +require ( + github.com/charmbracelet/bubbles v1.0.0 + github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/glamour v1.0.0 + github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 + golang.org/x/net v0.56.0 +) + +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.46.0 // indirect + golang.org/x/term v0.44.0 // indirect + golang.org/x/text v0.38.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..6654810 --- /dev/null +++ b/go.sum @@ -0,0 +1,91 @@ +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/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= +github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA= +github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= +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.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= +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.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= +golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= +golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= +golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= diff --git a/internal/client/client.go b/internal/client/client.go new file mode 100644 index 0000000..e45ea6c --- /dev/null +++ b/internal/client/client.go @@ -0,0 +1,149 @@ +// Package client speaks odek serve's WebSocket protocol. +// +// bodek does not re-implement any agent logic. It connects to a running +// `odek serve` instance and renders the events it streams — tokens, tool +// calls, approvals, skills, memory — so the full odek engine (tools, danger +// gating, sandbox, skills, memory, sessions) is reused as-is. This file is the +// thin transport + event-decoding layer between that engine and the TUI. +package client + +import ( + "encoding/json" + "fmt" + + ws "golang.org/x/net/websocket" +) + +// Event is a decoded server→client message. It is a union of every event the +// odek serve protocol emits; only the fields relevant to Type are populated. +type Event struct { + Type string `json:"type"` + + // tool_call / tool_result / subagent_log + Name string `json:"name"` + Data string `json:"data"` + + // token / thinking + Content string `json:"content"` + + // error + Message string `json:"message"` + + // session + SessionID string `json:"session_id"` + Model string `json:"model"` + Sandbox bool `json:"sandbox"` + + // done — token economics for the turn and the session + Latency float64 `json:"latency"` + ContextTokens int `json:"contextTokens"` + OutputTokens int `json:"outputTokens"` + SessionContextTokens int `json:"sessionContextTokens"` + SessionOutputTokens int `json:"sessionOutputTokens"` + + // approval_request + ID string `json:"id"` + Risk string `json:"risk"` + Command string `json:"command"` + Description string `json:"description"` + IsOperation bool `json:"is_operation"` + AllowTrust bool `json:"allow_trust"` + + // skill_event / memory_event / agent_signal / subagent_log: the event + // subtype (e.g. "loaded", "merge", "trim") plus a few shared details. + SubType string `json:"event"` + Target string `json:"target"` + Detail string `json:"detail"` + SkillName string `json:"skill_name"` + Untrusted bool `json:"untrusted"` + Count int `json:"count"` + TaskIdx int `json:"task_idx"` +} + +// EventDisconnected is a synthetic Type emitted on the Events channel when the +// socket closes, so the TUI can react instead of hanging. +const EventDisconnected = "_disconnected" + +// Client is a connected odek serve session. +type Client struct { + conn *ws.Conn + Events chan Event +} + +// Dial connects to an odek serve WebSocket. wsURL is the ws:// endpoint, +// origin is an http://localhost-based origin accepted by the server, and token +// is the per-instance CSRF token (obtained from a GET / Set-Cookie header). +func Dial(wsURL, origin, token string) (*Client, error) { + cfg, err := ws.NewConfig(wsURL, origin) + if err != nil { + return nil, fmt.Errorf("ws config: %w", err) + } + cfg.Header.Set("X-Odek-Ws-Token", token) + + conn, err := ws.DialConfig(cfg) + if err != nil { + return nil, fmt.Errorf("ws dial: %w", err) + } + + c := &Client{conn: conn, Events: make(chan Event, 256)} + go c.readLoop() + return c, nil +} + +// readLoop decodes frames into Events until the socket closes. +func (c *Client) readLoop() { + defer close(c.Events) + for { + var data []byte + if err := ws.Message.Receive(c.conn, &data); err != nil { + c.Events <- Event{Type: EventDisconnected} + return + } + var ev Event + if err := json.Unmarshal(data, &ev); err != nil { + continue // ignore malformed frames + } + c.Events <- ev + } +} + +// prompt is the client→server prompt message. +type prompt struct { + Type string `json:"type"` + Content string `json:"content"` + Thinking string `json:"thinking,omitempty"` + Model string `json:"model,omitempty"` +} + +// SendPrompt submits a task. thinking is "enabled" to force reasoning for this +// turn, or "" for the server default. model switches the active model when set. +// Session continuity is automatic: the server keeps one conversation per +// connection. +func (c *Client) SendPrompt(content, thinking, model string) error { + return ws.JSON.Send(c.conn, prompt{ + Type: "prompt", + Content: content, + Thinking: thinking, + Model: model, + }) +} + +// approval is the client→server response to an approval_request. +type approval struct { + Type string `json:"type"` + ID string `json:"id"` + Action string `json:"action"` // "approve" | "deny" | "trust" +} + +// SendApproval answers a pending approval_request. +func (c *Client) SendApproval(id, action string) error { + return ws.JSON.Send(c.conn, approval{Type: "approval_response", ID: id, Action: action}) +} + +// Close shuts the connection. +func (c *Client) Close() error { + if c.conn == nil { + return nil + } + return c.conn.Close() +} diff --git a/internal/client/client_test.go b/internal/client/client_test.go new file mode 100644 index 0000000..f00b3fe --- /dev/null +++ b/internal/client/client_test.go @@ -0,0 +1,81 @@ +package client + +import ( + "encoding/json" + "testing" +) + +// TestDecodeEvents verifies that representative odek serve frames decode into +// the union Event with the right fields populated. +func TestDecodeEvents(t *testing.T) { + cases := []struct { + name string + frame string + check func(t *testing.T, e Event) + }{ + { + name: "session", + frame: `{"type":"session","session_id":"20260618-abc","model":"deepseek-v4-flash","sandbox":true}`, + check: func(t *testing.T, e Event) { + if e.Type != "session" || e.SessionID != "20260618-abc" || e.Model != "deepseek-v4-flash" || !e.Sandbox { + t.Fatalf("bad session decode: %+v", e) + } + }, + }, + { + name: "token", + frame: `{"type":"token","content":"hello "}`, + check: func(t *testing.T, e Event) { + if e.Type != "token" || e.Content != "hello " { + t.Fatalf("bad token decode: %+v", e) + } + }, + }, + { + name: "tool_call", + frame: `{"type":"tool_call","name":"shell","data":"{\"command\":\"ls\"}"}`, + check: func(t *testing.T, e Event) { + if e.Type != "tool_call" || e.Name != "shell" || e.Data == "" { + t.Fatalf("bad tool_call decode: %+v", e) + } + }, + }, + { + name: "done", + frame: `{"type":"done","latency":4.2,"sessionContextTokens":1200,"sessionOutputTokens":340}`, + check: func(t *testing.T, e Event) { + if e.Type != "done" || e.Latency != 4.2 || e.SessionContextTokens != 1200 || e.SessionOutputTokens != 340 { + t.Fatalf("bad done decode: %+v", e) + } + }, + }, + { + name: "approval_request", + frame: `{"type":"approval_request","id":"apr-1","risk":"network_egress","command":"curl x","description":"fetch","allow_trust":true}`, + check: func(t *testing.T, e Event) { + if e.Type != "approval_request" || e.ID != "apr-1" || e.Risk != "network_egress" || !e.AllowTrust { + t.Fatalf("bad approval decode: %+v", e) + } + }, + }, + { + name: "memory_event", + frame: `{"type":"memory_event","event":"merge","target":"user","count":3}`, + check: func(t *testing.T, e Event) { + if e.Type != "memory_event" || e.SubType != "merge" || e.Target != "user" || e.Count != 3 { + t.Fatalf("bad memory_event decode: %+v", e) + } + }, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var e Event + if err := json.Unmarshal([]byte(tc.frame), &e); err != nil { + t.Fatalf("unmarshal: %v", err) + } + tc.check(t, e) + }) + } +} diff --git a/internal/server/server.go b/internal/server/server.go new file mode 100644 index 0000000..ac9c36b --- /dev/null +++ b/internal/server/server.go @@ -0,0 +1,177 @@ +// Package server launches and supervises an `odek serve` process, or attaches +// to one already running, and resolves the connection details bodek needs: +// the base HTTP URL, the WebSocket URL, and the per-instance auth token. +package server + +import ( + "context" + "fmt" + "io" + "net" + "net/http" + "os" + "os/exec" + "strings" + "time" +) + +const wsTokenCookie = "odek_ws_token" + +// Conn holds everything needed to talk to an odek serve instance. +type Conn struct { + BaseURL string // http://127.0.0.1:port + WSURL string // ws://127.0.0.1:port/ws + Origin string // http://127.0.0.1:port (accepted by the server's origin check) + Token string // per-instance CSRF token + + proc *exec.Cmd // non-nil when bodek spawned the server +} + +// Options configures how the odek serve instance is obtained. +type Options struct { + // URL of an already-running odek serve (e.g. "http://127.0.0.1:8080"). + // When set, bodek attaches instead of spawning. + URL string + + // Bin is the odek binary to spawn (default "odek"). Ignored when URL set. + Bin string + + // Sandbox toggles the Docker sandbox for a spawned server. odek serve + // defaults sandbox on; bodek defaults it off for a frictionless local TUI. + Sandbox bool + + // ExtraArgs are passed through to `odek serve` (e.g. model/config flags). + ExtraArgs []string + + // Stderr, if set, receives the spawned server's stderr. + Stderr io.Writer +} + +// Connect attaches to or launches an odek serve instance and resolves its +// auth token, returning a ready Conn. +func Connect(opts Options) (*Conn, error) { + c := &Conn{} + + if opts.URL != "" { + base := strings.TrimRight(opts.URL, "/") + c.BaseURL = base + c.Origin = base + c.WSURL = "ws" + strings.TrimPrefix(base, "http") + "/ws" + } else { + port, err := freePort() + if err != nil { + return nil, fmt.Errorf("allocate port: %w", err) + } + addr := fmt.Sprintf("127.0.0.1:%d", port) + c.BaseURL = "http://" + addr + c.Origin = c.BaseURL + c.WSURL = "ws://" + addr + "/ws" + if err := c.spawn(opts, addr); err != nil { + return nil, err + } + } + + if err := waitReady(c.BaseURL, 30*time.Second); err != nil { + c.Stop() + return nil, fmt.Errorf("odek serve did not become ready: %w", err) + } + + token, err := fetchToken(c.BaseURL) + if err != nil { + c.Stop() + return nil, fmt.Errorf("fetch auth token: %w", err) + } + c.Token = token + return c, nil +} + +func (c *Conn) spawn(opts Options, addr string) error { + bin := opts.Bin + if bin == "" { + bin = "odek" + } + if _, err := exec.LookPath(bin); err != nil { + return fmt.Errorf("cannot find %q on PATH — install odek or pass --url to attach to a running server", bin) + } + args := []string{"serve", "--addr", addr} + if !opts.Sandbox { + args = append(args, "--no-sandbox") + } else { + args = append(args, "--sandbox") + } + args = append(args, opts.ExtraArgs...) + + cmd := exec.Command(bin, args...) + cmd.Stderr = opts.Stderr + cmd.Env = os.Environ() + if err := cmd.Start(); err != nil { + return fmt.Errorf("start odek serve: %w", err) + } + c.proc = cmd + return nil +} + +// Stop terminates a spawned server (no-op when attached to an external one). +// This lets odek run its own cleanup: sandbox teardown, memory flush, etc. +func (c *Conn) Stop() { + if c == nil || c.proc == nil || c.proc.Process == nil { + return + } + // SIGINT triggers odek serve's graceful shutdown (closes sockets, removes + // sandbox containers). Fall back to Kill if it lingers. + _ = c.proc.Process.Signal(os.Interrupt) + done := make(chan struct{}) + go func() { _ = c.proc.Wait(); close(done) }() + select { + case <-done: + case <-time.After(8 * time.Second): + _ = c.proc.Process.Kill() + } +} + +// freePort asks the OS for an unused TCP port on the loopback interface. +func freePort() (int, error) { + l, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + return 0, err + } + defer l.Close() + return l.Addr().(*net.TCPAddr).Port, nil +} + +// waitReady polls the server root until it responds or the timeout elapses. +func waitReady(baseURL string, timeout time.Duration) error { + deadline := time.Now().Add(timeout) + client := &http.Client{Timeout: 2 * time.Second} + for time.Now().Before(deadline) { + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + req, _ := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+"/", nil) + resp, err := client.Do(req) + cancel() + if err == nil { + resp.Body.Close() + if resp.StatusCode < 500 { + return nil + } + } + time.Sleep(150 * time.Millisecond) + } + return fmt.Errorf("timed out after %s", timeout) +} + +// fetchToken performs GET / and reads the per-instance CSRF token from the +// odek_ws_token Set-Cookie header. +func fetchToken(baseURL string) (string, error) { + client := &http.Client{Timeout: 5 * time.Second} + resp, err := client.Get(baseURL + "/") + if err != nil { + return "", err + } + defer resp.Body.Close() + for _, ck := range resp.Cookies() { + if ck.Name == wsTokenCookie && ck.Value != "" { + return ck.Value, nil + } + } + return "", fmt.Errorf("server did not issue an %s cookie", wsTokenCookie) +} diff --git a/internal/tui/banner.go b/internal/tui/banner.go new file mode 100644 index 0000000..dc37698 --- /dev/null +++ b/internal/tui/banner.go @@ -0,0 +1,57 @@ +package tui + +import ( + "strings" + + "github.com/charmbracelet/lipgloss" +) + +// bannerArt is the BODEK wordmark in block characters. +var bannerArt = []string{ + "██████ ██████ ██████ ███████ ██ ██", + "██ ██ ██ ██ ██ ██ ██ ██ ██ ", + "██████ ██ ██ ██ ██ █████ █████ ", + "██ ██ ██ ██ ██ ██ ██ ██ ██ ", + "██████ ██████ ██████ ███████ ██ ██", +} + +// purple → pink gradient endpoints for the wordmark. +var ( + gradFrom = [3]int{0xA7, 0x8B, 0xFA} + gradTo = [3]int{0xF4, 0x72, 0xB6} +) + +// welcome renders the splash shown in the conversation area before the first +// prompt: the wordmark, a tagline, and a few key bindings. +func welcome(th theme, width int) string { + var b strings.Builder + for _, line := range bannerArt { + b.WriteString(gradient(line, gradFrom, gradTo)) + b.WriteByte('\n') + } + b.WriteByte('\n') + b.WriteString(th.tagline.Render("a beautiful terminal interface for the odek agent")) + b.WriteString("\n\n") + + tips := [][2]string{ + {"type a task", "and press enter to run the agent"}, + {"⏎ send", "· ^J newline · ^T toggle thinking"}, + {"^L clear", "· PgUp/PgDn scroll · ^C quit"}, + {"approvals", "answer with [a]pprove [d]eny [t]rust"}, + } + for _, t := range tips { + b.WriteString(" " + th.tipKey.Render(pad(t[0], 12)) + th.tipText.Render(t[1]) + "\n") + } + + block := b.String() + // Center the splash block within the available width for a polished look. + return lipgloss.NewStyle().Width(width).Align(lipgloss.Center).Render(block) +} + +// pad right-pads s with spaces to width n. +func pad(s string, n int) string { + if lipgloss.Width(s) >= n { + return s + } + return s + strings.Repeat(" ", n-lipgloss.Width(s)) +} diff --git a/internal/tui/helpers_test.go b/internal/tui/helpers_test.go new file mode 100644 index 0000000..dc1cae1 --- /dev/null +++ b/internal/tui/helpers_test.go @@ -0,0 +1,50 @@ +package tui + +import "testing" + +func TestArgPreview(t *testing.T) { + cases := []struct{ in, want string }{ + {`{"command":"go test ./..."}`, "go test ./..."}, + {`{"path":"main.go"}`, "main.go"}, + {`{"pattern":"func\\s+\\w+"}`, `func\s+\w+`}, + {``, ""}, + {`not json`, "not json"}, + } + for _, c := range cases { + if got := argPreview(c.in); got != c.want { + t.Errorf("argPreview(%q) = %q, want %q", c.in, got, c.want) + } + } +} + +func TestHuman(t *testing.T) { + cases := []struct { + in int + want string + }{ + {0, "0"}, + {42, "42"}, + {1234, "1.2k"}, + {2_500_000, "2.5M"}, + } + for _, c := range cases { + if got := human(c.in); got != c.want { + t.Errorf("human(%d) = %q, want %q", c.in, got, c.want) + } + } +} + +func TestTruncate(t *testing.T) { + if got := truncate("hello", 10); got != "hello" { + t.Errorf("no-truncate: got %q", got) + } + if got := truncate("hello world", 5); got != "hell…" { + t.Errorf("truncate: got %q", got) + } +} + +func TestCollapse(t *testing.T) { + if got := collapse("a\n b\t c"); got != "a b c" { + t.Errorf("collapse: got %q", got) + } +} diff --git a/internal/tui/messages.go b/internal/tui/messages.go new file mode 100644 index 0000000..6755553 --- /dev/null +++ b/internal/tui/messages.go @@ -0,0 +1,25 @@ +package tui + +import ( + tea "github.com/charmbracelet/bubbletea" + + "github.com/BackendStack21/bodek/internal/client" +) + +// eventMsg wraps a decoded server event for the Bubble Tea update loop. +type eventMsg client.Event + +// errMsg reports a local (non-protocol) failure, e.g. a failed socket write. +type errMsg struct{ err error } + +// listen blocks on the client's event channel and returns the next event as a +// tea.Msg. It is re-armed after each event so the stream is continuous. +func listen(ch <-chan client.Event) tea.Cmd { + return func() tea.Msg { + ev, ok := <-ch + if !ok { + return eventMsg{Type: client.EventDisconnected} + } + return eventMsg(ev) + } +} diff --git a/internal/tui/model.go b/internal/tui/model.go new file mode 100644 index 0000000..f1a4e3b --- /dev/null +++ b/internal/tui/model.go @@ -0,0 +1,455 @@ +package tui + +import ( + "encoding/json" + "strings" + + "github.com/charmbracelet/bubbles/spinner" + "github.com/charmbracelet/bubbles/textarea" + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/glamour" + + "github.com/BackendStack21/bodek/internal/client" +) + +// role identifies who authored a conversation entry. +type role int + +const ( + roleUser role = iota + roleAsst + roleNote +) + +// step is a single tool invocation within an assistant turn. +type step struct { + name string + arg string + result string + done bool +} + +// message is one entry in the transcript. +type message struct { + role role + content string // raw text/markdown + rendered string // cached glamour render (assistant, finalized) + steps []step + streaming bool +} + +// Options carries startup display info into the model. +type Options struct { + Model string + Sandbox bool + CWD string +} + +// Model is the Bubble Tea model for bodek. +type Model struct { + cl *client.Client + events <-chan client.Event + opts Options + th theme + + width, height int + ready bool + + vp viewport.Model + ta textarea.Model + sp spinner.Model + glam *glamour.TermRenderer + + msgs []message + curIdx int // index of the streaming assistant message, -1 when idle + busy bool + thinking strings.Builder + + approval *client.Event // pending approval, nil when none + + model string + sandbox bool + sessionID string + thinkOn bool + + sessCtxTok int + sessOutTok int + lastLatency float64 + + status string + notices []string + disconn bool + quitting bool +} + +// New builds the initial model. +func New(cl *client.Client, opts Options) *Model { + th := newTheme() + + ta := textarea.New() + ta.Placeholder = "Ask odek to build, fix, explore… (⏎ send · ^J newline)" + ta.Prompt = th.asstLabel.Render("┃ ") + ta.ShowLineNumbers = false + ta.CharLimit = 0 + ta.SetHeight(3) + ta.FocusedStyle.CursorLine = th.taCursorLine + ta.Focus() + + sp := spinner.New() + sp.Spinner = spinner.MiniDot + sp.Style = th.spinner + + return &Model{ + cl: cl, + events: cl.Events, + opts: opts, + th: th, + ta: ta, + sp: sp, + curIdx: -1, + model: opts.Model, + sandbox: opts.Sandbox, + thinkOn: false, + status: "ready", + } +} + +func (m *Model) Init() tea.Cmd { + return tea.Batch(textarea.Blink, m.sp.Tick, listen(m.events)) +} + +func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + return m, m.resize(msg.Width, msg.Height) + + case tea.KeyMsg: + return m.handleKey(msg) + + case spinner.TickMsg: + var cmd tea.Cmd + m.sp, cmd = m.sp.Update(msg) + if m.busy { + m.refresh() + } + return m, cmd + + case errMsg: + m.busy = false + m.status = "error" + m.addNote("error: " + msg.err.Error()) + m.refresh() + return m, nil + + case eventMsg: + return m.handleEvent(client.Event(msg)) + + case tea.MouseMsg: + var cmd tea.Cmd + m.vp, cmd = m.vp.Update(msg) + return m, cmd + } + + // Forward anything else to the focused input. + var cmd tea.Cmd + m.ta, cmd = m.ta.Update(msg) + return m, cmd +} + +func (m *Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + // Approval mode captures the keyboard until answered. + if m.approval != nil { + switch msg.String() { + case "a", "y": + return m, m.answer("approve") + case "d", "n": + return m, m.answer("deny") + case "t": + if m.approval.AllowTrust { + return m, m.answer("trust") + } + case "ctrl+c": + m.quitting = true + return m, tea.Quit + } + return m, nil + } + + switch msg.String() { + case "ctrl+c": + m.quitting = true + return m, tea.Quit + case "enter": + return m, m.submit() + case "ctrl+j": + // Insert a newline into the textarea. + var cmd tea.Cmd + m.ta, cmd = m.ta.Update(tea.KeyMsg{Type: tea.KeyEnter}) + return m, cmd + case "ctrl+t": + m.thinkOn = !m.thinkOn + return m, nil + case "ctrl+l": + if !m.busy { + m.msgs = nil + m.refresh() + } + return m, nil + case "pgup", "pgdown", "ctrl+u", "ctrl+d": + var cmd tea.Cmd + m.vp, cmd = m.vp.Update(msg) + return m, cmd + } + + var cmd tea.Cmd + m.ta, cmd = m.ta.Update(msg) + return m, cmd +} + +func (m *Model) handleEvent(ev client.Event) (tea.Model, tea.Cmd) { + switch ev.Type { + case "session": + m.sessionID = ev.SessionID + if ev.Model != "" { + m.model = ev.Model + } + m.sandbox = ev.Sandbox + + case "thinking": + m.thinking.WriteString(ev.Content) + m.status = "thinking" + + case "token": + if i := m.cur(); i >= 0 { + m.msgs[i].content += ev.Content + m.msgs[i].streaming = true + } + m.status = "responding" + + case "tool_call": + if i := m.cur(); i >= 0 { + m.msgs[i].steps = append(m.msgs[i].steps, step{name: ev.Name, arg: argPreview(ev.Data)}) + } + m.status = "running " + ev.Name + + case "tool_result": + if i := m.cur(); i >= 0 { + steps := m.msgs[i].steps + for j := len(steps) - 1; j >= 0; j-- { + if steps[j].name == ev.Name && !steps[j].done { + steps[j].done = true + steps[j].result = linePreview(ev.Data) + break + } + } + } + + case "done": + m.finalize() + m.busy = false + m.status = "ready" + m.sessCtxTok = ev.SessionContextTokens + m.sessOutTok = ev.SessionOutputTokens + m.lastLatency = ev.Latency + + case "error": + if i := m.cur(); i >= 0 && m.msgs[i].content == "" { + m.msgs[i].content = "**Error:** " + ev.Message + } else { + m.addNote("error: " + ev.Message) + } + m.finalize() + m.busy = false + m.status = "error" + + case "approval_request": + e := ev + m.approval = &e + m.status = "approval required" + + case "skill_event": + m.addNote("skill · " + strings.TrimSpace(ev.SubType+" "+ev.SkillName)) + case "memory_event": + m.addNote("memory · " + strings.TrimSpace(ev.SubType+" "+ev.Target)) + case "agent_signal": + m.addNote("signal · " + strings.TrimSpace(ev.SubType+" "+ev.Detail)) + case "subagent_log": + m.addNote("subagent · " + strings.TrimSpace(ev.SubType+" "+ev.Name)) + + case client.EventDisconnected: + m.disconn = true + m.busy = false + m.status = "disconnected" + m.addNote("disconnected from odek serve") + m.refresh() + return m, nil + } + + m.refresh() + return m, listen(m.events) +} + +// ── actions ────────────────────────────────────────────────────────────── + +func (m *Model) submit() tea.Cmd { + if m.busy || m.disconn { + return nil + } + text := strings.TrimSpace(m.ta.Value()) + if text == "" { + return nil + } + m.msgs = append(m.msgs, message{role: roleUser, content: text}) + m.msgs = append(m.msgs, message{role: roleAsst, streaming: true}) + m.curIdx = len(m.msgs) - 1 + m.ta.Reset() + m.busy = true + m.status = "thinking" + m.thinking.Reset() + m.refresh() + + thinking := "" + if m.thinkOn { + thinking = "enabled" + } + cl := m.cl + return func() tea.Msg { + if err := cl.SendPrompt(text, thinking, ""); err != nil { + return errMsg{err} + } + return nil + } +} + +func (m *Model) answer(action string) tea.Cmd { + id := m.approval.ID + m.approval = nil + m.status = "thinking" + m.refresh() + cl := m.cl + return func() tea.Msg { + if err := cl.SendApproval(id, action); err != nil { + return errMsg{err} + } + return nil + } +} + +// ── helpers ──────────────────────────────────────────────────────────────── + +// cur returns the index of the active streaming assistant message, or -1. +func (m *Model) cur() int { + if m.curIdx >= 0 && m.curIdx < len(m.msgs) { + return m.curIdx + } + return -1 +} + +// finalize closes out the streaming assistant message, rendering its markdown. +func (m *Model) finalize() { + if i := m.cur(); i >= 0 { + m.msgs[i].streaming = false + m.msgs[i].rendered = m.render(m.msgs[i].content) + } + m.curIdx = -1 + m.thinking.Reset() +} + +func (m *Model) addNote(s string) { + m.notices = append(m.notices, s) + if len(m.notices) > 6 { + m.notices = m.notices[len(m.notices)-6:] + } +} + +// render runs content through glamour; falls back to raw text on error. +func (m *Model) render(content string) string { + if m.glam == nil || strings.TrimSpace(content) == "" { + return content + } + out, err := m.glam.Render(content) + if err != nil { + return content + } + return strings.TrimRight(out, "\n") +} + +func (m *Model) resize(w, h int) tea.Cmd { + m.width, m.height = w, h + + vpHeight := h - headerHeight - inputHeight - footerHeight + if vpHeight < 3 { + vpHeight = 3 + } + if !m.ready { + m.vp = viewport.New(w, vpHeight) + m.ready = true + } else { + m.vp.Width = w + m.vp.Height = vpHeight + } + m.ta.SetWidth(w - 4) + + wrap := w - 6 + if wrap < 20 { + wrap = 20 + } + if r, err := glamour.NewTermRenderer( + glamour.WithStandardStyle("dark"), + glamour.WithWordWrap(wrap), + ); err == nil { + m.glam = r + // Re-render finalized assistant messages at the new width. + for i := range m.msgs { + if m.msgs[i].role == roleAsst && !m.msgs[i].streaming { + m.msgs[i].rendered = m.render(m.msgs[i].content) + } + } + } + m.refresh() + return nil +} + +// argPreview extracts a short, human-friendly summary from a tool's JSON args. +func argPreview(data string) string { + data = strings.TrimSpace(data) + if data == "" { + return "" + } + var m map[string]any + if err := json.Unmarshal([]byte(data), &m); err != nil { + return truncate(collapse(data), 72) + } + for _, key := range []string{"command", "cmd", "path", "file", "pattern", "query", "url"} { + if v, ok := m[key]; ok { + if s, ok := v.(string); ok && s != "" { + return truncate(collapse(s), 72) + } + } + } + parts := make([]string, 0, len(m)) + for _, v := range m { + if s, ok := v.(string); ok && s != "" { + parts = append(parts, s) + } + } + return truncate(collapse(strings.Join(parts, " ")), 72) +} + +// linePreview returns the first meaningful line of tool output, truncated. +func linePreview(data string) string { + return truncate(collapse(data), 72) +} + +func collapse(s string) string { + return strings.Join(strings.Fields(s), " ") +} + +func truncate(s string, n int) string { + r := []rune(s) + if len(r) <= n { + return s + } + return string(r[:n-1]) + "…" +} diff --git a/internal/tui/styles.go b/internal/tui/styles.go new file mode 100644 index 0000000..4820d58 --- /dev/null +++ b/internal/tui/styles.go @@ -0,0 +1,145 @@ +package tui + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/lipgloss" +) + +// Palette — a cohesive, Charm-inspired dark theme. Purple/pink brand accents +// over soft greys, with semantic colors for status and tool activity. +var ( + colBrand = lipgloss.Color("#A78BFA") // primary purple + colBrand2 = lipgloss.Color("#F472B6") // pink (gradient end / user) + colCyan = lipgloss.Color("#67E8F9") + colGreen = lipgloss.Color("#34D399") + colYellow = lipgloss.Color("#FBBF24") + colRed = lipgloss.Color("#F87171") + colFg = lipgloss.Color("#E5E7EB") + colMuted = lipgloss.Color("#9CA3AF") + colFaint = lipgloss.Color("#6B7280") + colHairline = lipgloss.Color("#3B3B4F") +) + +// Layout — fixed heights for the chrome around the scrollable transcript. +const ( + headerHeight = 2 // logo bar + hairline rule + inputHeight = 5 // bordered textarea (3 rows + top/bottom border) + footerHeight = 1 // keybinding / status line +) + +// theme holds every reusable style. Built once and shared by the model. +type theme struct { + logo lipgloss.Style + headerMeta lipgloss.Style + headerKey lipgloss.Style + rule lipgloss.Style + + userLabel lipgloss.Style + userBar lipgloss.Style + asstLabel lipgloss.Style + asstBar lipgloss.Style + sysLabel lipgloss.Style + sysBar lipgloss.Style + + stepName lipgloss.Style + stepArg lipgloss.Style + stepRun lipgloss.Style + stepDone lipgloss.Style + stepRes lipgloss.Style + + spinner lipgloss.Style + + taCursorLine lipgloss.Style + inputBox lipgloss.Style + + noticeStyle lipgloss.Style + thinkStyle lipgloss.Style + + apprBox lipgloss.Style + apprHead lipgloss.Style + apprBody lipgloss.Style + apprKey lipgloss.Style + + statusReady lipgloss.Style + statusBusy lipgloss.Style + + footer lipgloss.Style + footerKey lipgloss.Style + footerSep lipgloss.Style + + tagline lipgloss.Style + tipKey lipgloss.Style + tipText lipgloss.Style +} + +func newTheme() theme { + return theme{ + logo: lipgloss.NewStyle().Bold(true), + headerMeta: lipgloss.NewStyle().Foreground(colMuted), + headerKey: lipgloss.NewStyle().Foreground(colCyan), + rule: lipgloss.NewStyle().Foreground(colHairline), + + userLabel: lipgloss.NewStyle().Foreground(colBrand2).Bold(true), + userBar: lipgloss.NewStyle().Foreground(colFg).Border(lipgloss.ThickBorder(), false, false, false, true).BorderForeground(colBrand2).PaddingLeft(1), + asstLabel: lipgloss.NewStyle().Foreground(colBrand).Bold(true), + asstBar: lipgloss.NewStyle().Border(lipgloss.ThickBorder(), false, false, false, true).BorderForeground(colBrand).PaddingLeft(1), + sysLabel: lipgloss.NewStyle().Foreground(colRed).Bold(true), + sysBar: lipgloss.NewStyle().Foreground(colRed).Border(lipgloss.ThickBorder(), false, false, false, true).BorderForeground(colRed).PaddingLeft(1), + + stepName: lipgloss.NewStyle().Foreground(colCyan), + stepArg: lipgloss.NewStyle().Foreground(colFaint), + stepRun: lipgloss.NewStyle().Foreground(colYellow), + stepDone: lipgloss.NewStyle().Foreground(colGreen), + stepRes: lipgloss.NewStyle().Foreground(colFaint).Italic(true), + + spinner: lipgloss.NewStyle().Foreground(colBrand), + + taCursorLine: lipgloss.NewStyle(), + inputBox: lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(colHairline).Padding(0, 1), + + noticeStyle: lipgloss.NewStyle().Foreground(colFaint).Italic(true), + thinkStyle: lipgloss.NewStyle().Foreground(colFaint).Italic(true), + + apprBox: lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(colYellow).Padding(0, 1), + apprHead: lipgloss.NewStyle().Foreground(colYellow).Bold(true), + apprBody: lipgloss.NewStyle().Foreground(colFg), + apprKey: lipgloss.NewStyle().Foreground(colGreen).Bold(true), + + statusReady: lipgloss.NewStyle().Foreground(colGreen), + statusBusy: lipgloss.NewStyle().Foreground(colYellow), + + footer: lipgloss.NewStyle().Foreground(colFaint), + footerKey: lipgloss.NewStyle().Foreground(colMuted).Bold(true), + footerSep: lipgloss.NewStyle().Foreground(colHairline), + + tagline: lipgloss.NewStyle().Foreground(colMuted).Italic(true), + tipKey: lipgloss.NewStyle().Foreground(colCyan).Bold(true), + tipText: lipgloss.NewStyle().Foreground(colMuted), + } +} + +// gradient colors a string left-to-right by interpolating between two RGB +// endpoints — used for the banner. Whitespace is passed through untouched. +func gradient(s string, from, to [3]int) string { + runes := []rune(s) + n := len(runes) + var b strings.Builder + for i, r := range runes { + if r == ' ' { + b.WriteRune(r) + continue + } + t := 0.0 + if n > 1 { + t = float64(i) / float64(n-1) + } + cr := int(float64(from[0]) + float64(to[0]-from[0])*t) + cg := int(float64(from[1]) + float64(to[1]-from[1])*t) + cb := int(float64(from[2]) + float64(to[2]-from[2])*t) + col := lipgloss.Color(fmt.Sprintf("#%02X%02X%02X", cr, cg, cb)) + b.WriteString(lipgloss.NewStyle().Foreground(col).Render(string(r))) + } + return b.String() +} diff --git a/internal/tui/view.go b/internal/tui/view.go new file mode 100644 index 0000000..762d9df --- /dev/null +++ b/internal/tui/view.go @@ -0,0 +1,268 @@ +package tui + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/lipgloss" +) + +// View composes the full screen: header, scrollable transcript, input (or +// approval prompt), and footer. +func (m *Model) View() string { + if !m.ready { + return "\n starting bodek…" + } + parts := []string{ + m.header(), + m.vp.View(), + m.inputArea(), + m.footer(), + } + return strings.Join(parts, "\n") +} + +// ── header ───────────────────────────────────────────────────────────────── + +func (m *Model) header() string { + th := m.th + logo := th.logo.Render(gradient("⬡ bodek", gradFrom, gradTo)) + + sandbox := "off" + if m.sandbox { + sandbox = "on" + } + think := "off" + if m.thinkOn { + think = "on" + } + modelName := m.model + if modelName == "" { + modelName = "default" + } + meta := th.headerMeta.Render(fmt.Sprintf("%s · sandbox %s · think %s", + th.headerKey.Render(modelName), sandbox, think)) + + left := logo + th.headerMeta.Render(" "+meta) + + status := m.statusBadge() + tokens := th.headerMeta.Render(fmt.Sprintf("∑ ⌂ %s · ⎇ %s", + human(m.sessCtxTok), human(m.sessOutTok))) + right := tokens + " " + status + + gap := m.width - lipgloss.Width(left) - lipgloss.Width(right) + if gap < 1 { + gap = 1 + } + bar := left + strings.Repeat(" ", gap) + right + rule := th.rule.Render(strings.Repeat("─", max(m.width, 1))) + return bar + "\n" + rule +} + +func (m *Model) statusBadge() string { + th := m.th + switch { + case m.disconn: + return lipgloss.NewStyle().Foreground(colRed).Render("● disconnected") + case m.approval != nil: + return th.statusBusy.Render("● approval required") + case m.busy: + return th.statusBusy.Render(m.sp.View() + " " + m.status) + default: + return th.statusReady.Render("● " + m.status) + } +} + +// ── transcript ─────────────────────────────────────────────────────────── + +// refresh rebuilds the viewport content and scrolls to the latest output. +func (m *Model) refresh() { + if !m.ready { + return + } + m.vp.SetContent(m.conversation()) + m.vp.GotoBottom() +} + +func (m *Model) conversation() string { + if len(m.msgs) == 0 { + return welcome(m.th, m.vp.Width) + } + blocks := make([]string, 0, len(m.msgs)+1) + for i := range m.msgs { + blocks = append(blocks, m.renderMessage(m.msgs[i])) + } + // Live reasoning for the in-flight turn, shown dimly under the steps. + if m.busy && m.thinking.Len() > 0 { + think := m.th.thinkStyle.Render("… " + collapse(m.thinking.String())) + blocks = append(blocks, lipgloss.NewStyle().Width(m.vp.Width).Render(think)) + } + if len(m.notices) > 0 { + blocks = append(blocks, m.renderNotices()) + } + return strings.Join(blocks, "\n\n") +} + +func (m *Model) renderMessage(msg message) string { + th := m.th + switch msg.role { + case roleUser: + label := th.userLabel.Render("❯ you") + body := th.userBar.Width(m.vp.Width - 2).Render(msg.content) + return label + "\n" + body + + case roleNote: + return th.sysBar.Width(m.vp.Width - 2).Render(msg.content) + + default: // assistant + label := th.asstLabel.Render("⬡ odek") + var b strings.Builder + if steps := m.renderSteps(msg); steps != "" { + b.WriteString(steps) + if strings.TrimSpace(msg.content) != "" { + b.WriteString("\n") + } + } + content := msg.content + if !msg.streaming && msg.rendered != "" { + content = msg.rendered + } + if strings.TrimSpace(content) == "" && msg.streaming { + content = th.thinkStyle.Render(m.sp.View() + " thinking…") + } + b.WriteString(content) + body := th.asstBar.Width(m.vp.Width - 2).Render(strings.TrimRight(b.String(), "\n")) + return label + "\n" + body + } +} + +func (m *Model) renderSteps(msg message) string { + if len(msg.steps) == 0 { + return "" + } + th := m.th + lines := make([]string, 0, len(msg.steps)) + for _, s := range msg.steps { + var icon string + switch { + case s.done: + icon = th.stepDone.Render("✓") + case msg.streaming: + icon = m.sp.View() + default: + icon = th.stepRun.Render("▸") + } + line := icon + " " + th.stepName.Render(s.name) + if s.arg != "" { + line += th.stepArg.Render(" " + s.arg) + } + lines = append(lines, line) + if s.done && s.result != "" { + lines = append(lines, th.stepRes.Render(" ↳ "+s.result)) + } + } + return strings.Join(lines, "\n") +} + +func (m *Model) renderNotices() string { + th := m.th + lines := make([]string, len(m.notices)) + for i, n := range m.notices { + lines[i] = th.noticeStyle.Render("· " + n) + } + return strings.Join(lines, "\n") +} + +// ── input / approval area ────────────────────────────────────────────────── + +func (m *Model) inputArea() string { + if m.approval != nil { + return m.approvalPanel() + } + return m.th.inputBox.Width(m.width - 2).Render(m.ta.View()) +} + +func (m *Model) approvalPanel() string { + th := m.th + a := m.approval + head := th.apprHead.Render(fmt.Sprintf("⚠ approval required · risk: %s", orDash(a.Risk))) + + target := a.Command + if a.Name != "" { + target = a.Name + ": " + target + } + cmd := th.apprBody.Render(truncate(collapse(target), m.width-8)) + + desc := "" + if a.Description != "" { + desc = th.noticeStyle.Render(truncate(collapse(a.Description), m.width-8)) + "\n" + } + + keys := th.apprKey.Render("[a]") + th.apprBody.Render(" approve ") + + th.apprKey.Render("[d]") + th.apprBody.Render(" deny") + if a.AllowTrust { + keys += th.apprKey.Render(" [t]") + th.apprBody.Render(" trust class") + } + + body := head + "\n" + cmd + "\n" + desc + keys + return th.apprBox.Width(m.width - 2).Render(body) +} + +// ── footer ───────────────────────────────────────────────────────────────── + +func (m *Model) footer() string { + th := m.th + if m.approval != nil { + return th.footer.Render(" answer the approval prompt to continue") + } + if m.disconn { + return th.footer.Render(" connection closed · press ^C to quit") + } + sep := th.footerSep.Render(" · ") + keys := []string{ + th.footerKey.Render("⏎") + th.footer.Render(" send"), + th.footerKey.Render("^J") + th.footer.Render(" newline"), + th.footerKey.Render("^T") + th.footer.Render(" thinking"), + th.footerKey.Render("^L") + th.footer.Render(" clear"), + th.footerKey.Render("^C") + th.footer.Render(" quit"), + } + left := " " + strings.Join(keys, sep) + + right := "" + if m.lastLatency > 0 { + right = th.footer.Render(fmt.Sprintf("%.1fs ", m.lastLatency)) + } + gap := m.width - lipgloss.Width(left) - lipgloss.Width(right) + if gap < 1 { + gap = 1 + } + return left + strings.Repeat(" ", gap) + right +} + +// ── small helpers ────────────────────────────────────────────────────────── + +func orDash(s string) string { + if s == "" { + return "—" + } + return s +} + +// human formats a token count compactly (e.g. 1234 → "1.2k"). +func human(n int) string { + switch { + case n >= 1_000_000: + return fmt.Sprintf("%.1fM", float64(n)/1_000_000) + case n >= 1_000: + return fmt.Sprintf("%.1fk", float64(n)/1_000) + default: + return fmt.Sprintf("%d", n) + } +} + +func max(a, b int) int { + if a > b { + return a + } + return b +} From 7145829c6af09fb281f4cd9231f5a33ef2c5837c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 18 Jun 2026 08:14:37 +0000 Subject: [PATCH 2/5] feat: polish pass + @-reference file attachments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make bodek feel premium and fluent, and add file/session attachments by reusing odek serve's resource index — no new agent logic. Fluency & polish: - smooth braille spinner; live elapsed timer and cycling status verbs while the agent reasons - per-tool glyphs in the activity feed (shell, read, search, browser…) - gradient hairline under the header (cached per width) - smart autoscroll: follows the stream while busy, never yanks you when scrolled up reading history; scroll-position indicator in the footer File attachments (@-references): - internal/client: Resources() queries odek's /api/resources endpoint - internal/tui: live @-completion popup (↑/↓ select, ⏎/⇥ insert, esc cancel) for files and sessions; dynamic relayout reserves its space - references resolve server-side via odek's resource registry, going through the same untrusted-content boundary as any external input Tests: resource-event decode, ref parsing (activeRef/refStart), tool glyphs, and a no-TTY smoke test driving a full streaming turn plus the approval and autocomplete panels at full and narrow widths. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_012rWqu7pktbd2ejfNw3a7Vf --- README.md | 31 ++++- internal/client/client.go | 50 ++++++- internal/tui/banner.go | 1 + internal/tui/helpers_test.go | 41 ++++++ internal/tui/icons.go | 44 +++++++ internal/tui/model.go | 218 +++++++++++++++++++++++++++++-- internal/tui/model_smoke_test.go | 107 +++++++++++++++ internal/tui/styles.go | 22 ++++ internal/tui/view.go | 119 +++++++++++++++-- 9 files changed, 599 insertions(+), 34 deletions(-) create mode 100644 internal/tui/icons.go create mode 100644 internal/tui/model_smoke_test.go diff --git a/README.md b/README.md index d8f61a0..5aab86c 100644 --- a/README.md +++ b/README.md @@ -77,12 +77,32 @@ by `odek serve` from its usual chain — `~/.odek/config.json` → `./odek.json` | Key | Action | |-----|--------| | `⏎` | Send the prompt | +| `@` | Open file/session reference completion (see below) | | `^J` | Insert a newline in the input | | `^T` | Toggle extended thinking for the next turn | | `^L` | Clear the conversation | | `PgUp` / `PgDn` / wheel | Scroll the transcript | | `^C` | Quit | +### File attachments / `@` references + +Type `@` in the input to attach context. bodek queries odek's resource index +live and shows a completion popup; `↑`/`↓` to choose, `⏎` or `⇥` to insert, +`esc` to dismiss. + +| Reference | Resolves to | +|-----------|-------------| +| `@path/to/file` | The file's contents, inlined into your prompt | +| `@sess:` | A saved session transcript | + +``` +> summarize @internal/client/client.go and compare it with @sess:20260618-ab12 +``` + +odek resolves and inlines the referenced content **server-side** (wrapped in +its untrusted-content boundary), so attachments go through the same security +model as any other external input — bodek doesn't special-case them. + When the agent requests approval for a dangerous operation, answer inline: | Key | Action | @@ -96,12 +116,17 @@ When the agent requests approval for a dangerous operation, answer inline: ## What you see - **Streaming answers** rendered as Markdown ([glamour](https://github.com/charmbracelet/glamour)). -- **Tool activity** — every `tool_call`/`tool_result` shown live with a spinner, - argument preview, and a one-line result. +- **Tool activity** — every `tool_call`/`tool_result` shown live with a glyph + per tool, a spinner, an argument preview, and a one-line result. - **Security approvals** — odek's `danger` engine prompts surface as an inline panel; your answer is sent straight back over the socket. -- **Live reasoning** — the model's pre-tool thinking streams in dimmed text. +- **Live reasoning** — the model's pre-tool thinking streams in dimmed text, + with a running elapsed timer and cycling status while it works. +- **`@` autocomplete** — a live, navigable popup of files and sessions. - **Telemetry** — session token totals and last-turn latency in the chrome. +- **Fluent by default** — gradient wordmark and hairline, smooth braille + spinner, smart autoscroll that never yanks you while you read history, and a + scroll-position indicator. - **Engine notices** — skill loads, memory merges, and agent signals appear as quiet status lines. diff --git a/internal/client/client.go b/internal/client/client.go index e45ea6c..42ca784 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -10,6 +10,9 @@ package client import ( "encoding/json" "fmt" + "net/http" + "net/url" + "time" ws "golang.org/x/net/websocket" ) @@ -64,16 +67,27 @@ type Event struct { // socket closes, so the TUI can react instead of hanging. const EventDisconnected = "_disconnected" +// Resource is a single @-reference completion candidate from /api/resources. +type Resource struct { + ID string `json:"id"` // full reference, e.g. "@src/main.go" + Type string `json:"type"` // "file" | "session" | "skill" + Label string `json:"label"` // display label + Detail string `json:"detail"` // one-line description +} + // Client is a connected odek serve session. type Client struct { - conn *ws.Conn - Events chan Event + conn *ws.Conn + baseURL string + http *http.Client + Events chan Event } // Dial connects to an odek serve WebSocket. wsURL is the ws:// endpoint, -// origin is an http://localhost-based origin accepted by the server, and token -// is the per-instance CSRF token (obtained from a GET / Set-Cookie header). -func Dial(wsURL, origin, token string) (*Client, error) { +// origin is an http://localhost-based origin accepted by the server, baseURL is +// the http:// root (used for the resource-search API), and token is the +// per-instance CSRF token (obtained from a GET / Set-Cookie header). +func Dial(wsURL, origin, baseURL, token string) (*Client, error) { cfg, err := ws.NewConfig(wsURL, origin) if err != nil { return nil, fmt.Errorf("ws config: %w", err) @@ -85,11 +99,35 @@ func Dial(wsURL, origin, token string) (*Client, error) { return nil, fmt.Errorf("ws dial: %w", err) } - c := &Client{conn: conn, Events: make(chan Event, 256)} + c := &Client{ + conn: conn, + baseURL: baseURL, + http: &http.Client{Timeout: 3 * time.Second}, + Events: make(chan Event, 256), + } go c.readLoop() return c, nil } +// Resources queries the server's @-reference completion endpoint. +func (c *Client) Resources(query string, limit int) ([]Resource, error) { + u := fmt.Sprintf("%s/api/resources?q=%s&limit=%d", + c.baseURL, url.QueryEscape(query), limit) + resp, err := c.http.Get(u) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("resources: status %s", resp.Status) + } + var out []Resource + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + return nil, err + } + return out, nil +} + // readLoop decodes frames into Events until the socket closes. func (c *Client) readLoop() { defer close(c.Events) diff --git a/internal/tui/banner.go b/internal/tui/banner.go index dc37698..4e11f22 100644 --- a/internal/tui/banner.go +++ b/internal/tui/banner.go @@ -35,6 +35,7 @@ func welcome(th theme, width int) string { tips := [][2]string{ {"type a task", "and press enter to run the agent"}, + {"@ to attach", "reference files & sessions, e.g. @main.go"}, {"⏎ send", "· ^J newline · ^T toggle thinking"}, {"^L clear", "· PgUp/PgDn scroll · ^C quit"}, {"approvals", "answer with [a]pprove [d]eny [t]rust"}, diff --git a/internal/tui/helpers_test.go b/internal/tui/helpers_test.go index dc1cae1..22431ba 100644 --- a/internal/tui/helpers_test.go +++ b/internal/tui/helpers_test.go @@ -48,3 +48,44 @@ func TestCollapse(t *testing.T) { t.Errorf("collapse: got %q", got) } } + +func TestActiveRef(t *testing.T) { + cases := []struct { + in string + want string + match bool + }{ + {"explain @main", "main", true}, + {"@", "", true}, + {"look at @src/app.go", "src/app.go", true}, + {"no ref here", "", false}, + {"email a@b.com", "", false}, // '@' not at a token boundary + {"@sess:abc done", "", false}, // ref already terminated by space + } + for _, c := range cases { + got, ok := activeRef(c.in) + if ok != c.match || (ok && got != c.want) { + t.Errorf("activeRef(%q) = (%q,%v), want (%q,%v)", c.in, got, ok, c.want, c.match) + } + } +} + +func TestRefStart(t *testing.T) { + in := "please read @internal/x" + idx, ok := refStart(in) + if !ok || in[idx] != '@' { + t.Fatalf("refStart(%q) = %d,%v (char %q)", in, idx, ok, in[idx]) + } + if in[:idx] != "please read " { + t.Errorf("prefix = %q", in[:idx]) + } +} + +func TestToolGlyph(t *testing.T) { + if toolGlyph("shell") == toolGlyph("read_file") { + t.Error("expected distinct glyphs for shell and read_file") + } + if toolGlyph("totally_unknown_tool") != "✦" { + t.Errorf("unknown tool glyph = %q", toolGlyph("totally_unknown_tool")) + } +} diff --git a/internal/tui/icons.go b/internal/tui/icons.go new file mode 100644 index 0000000..05dba24 --- /dev/null +++ b/internal/tui/icons.go @@ -0,0 +1,44 @@ +package tui + +import "strings" + +// toolGlyph returns a tasteful monochrome glyph for a tool, so the activity +// feed reads at a glance. Matching is by substring to cover odek's native +// tools, MCP tools (server__tool), and sub-agent variants. +func toolGlyph(name string) string { + n := strings.ToLower(name) + switch { + case strings.Contains(n, "shell"), strings.Contains(n, "bash"), strings.Contains(n, "exec"): + return "❯" + case strings.Contains(n, "write"), strings.Contains(n, "patch"), strings.Contains(n, "edit"): + return "✎" + case strings.Contains(n, "read"): + return "◰" + case strings.Contains(n, "list"), strings.Contains(n, "dir"), strings.Contains(n, "ls"): + return "▤" + case strings.Contains(n, "search"), strings.Contains(n, "grep"), strings.Contains(n, "find"): + return "⌕" + case strings.Contains(n, "browser"), strings.Contains(n, "http"), strings.Contains(n, "fetch"), strings.Contains(n, "web"): + return "◉" + case strings.Contains(n, "delegate"), strings.Contains(n, "subagent"), strings.Contains(n, "task"): + return "⑂" + case strings.Contains(n, "memory"), strings.Contains(n, "recall"): + return "❖" + case strings.Contains(n, "vision"), strings.Contains(n, "image"), strings.Contains(n, "transcribe"): + return "◎" + default: + return "✦" + } +} + +// resourceGlyph returns a glyph for an @-reference result type. +func resourceGlyph(typ string) string { + switch typ { + case "session": + return "⟳" + case "skill": + return "✦" + default: // file + return "≡" + } +} diff --git a/internal/tui/model.go b/internal/tui/model.go index f1a4e3b..d9af093 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -2,7 +2,10 @@ package tui import ( "encoding/json" + "fmt" + "regexp" "strings" + "time" "github.com/charmbracelet/bubbles/spinner" "github.com/charmbracelet/bubbles/textarea" @@ -65,8 +68,11 @@ type Model struct { curIdx int // index of the streaming assistant message, -1 when idle busy bool thinking strings.Builder + runStart time.Time + lastTool string approval *client.Event // pending approval, nil when none + ac autocomplete // @-reference completion state model string sandbox bool @@ -81,6 +87,38 @@ type Model struct { notices []string disconn bool quitting bool + + gradRule string // cached full-width gradient rule + gradRuleW int +} + +// autocomplete holds the @-reference completion popup state. +type autocomplete struct { + open bool + loading bool + query string + items []client.Resource + sel int + seq int // request sequence, to drop stale responses +} + +// rows is the number of list rows the popup renders. +func (a autocomplete) rows() int { + if len(a.items) == 0 { + return 1 // "searching…" / "no matches" + } + return len(a.items) +} + +// height is the total rendered height of the popup (border + title + rows). +func (a autocomplete) height() int { + return a.rows() + 3 +} + +// acResultMsg carries the result of an async resource search. +type acResultMsg struct { + seq int + items []client.Resource } // New builds the initial model. @@ -97,7 +135,11 @@ func New(cl *client.Client, opts Options) *Model { ta.Focus() sp := spinner.New() - sp.Spinner = spinner.MiniDot + // A smooth braille spinner reads as fluid motion at small size. + sp.Spinner = spinner.Spinner{ + Frames: []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}, + FPS: time.Second / 12, + } sp.Style = th.spinner return &Model{ @@ -130,7 +172,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case spinner.TickMsg: var cmd tea.Cmd m.sp, cmd = m.sp.Update(msg) - if m.busy { + if m.busy || m.ac.loading { m.refresh() } return m, cmd @@ -142,6 +184,19 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.refresh() return m, nil + case acResultMsg: + if msg.seq != m.ac.seq { + return m, nil // stale response + } + m.ac.loading = false + m.ac.items = msg.items + if m.ac.sel >= len(m.ac.items) { + m.ac.sel = 0 + } + m.relayout() + m.refresh() + return m, nil + case eventMsg: return m.handleEvent(client.Event(msg)) @@ -176,6 +231,30 @@ func (m *Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil } + // The @-reference popup captures navigation keys while open. + if m.ac.open { + switch msg.String() { + case "up", "ctrl+p": + if m.ac.sel > 0 { + m.ac.sel-- + m.refresh() + } + return m, nil + case "down", "ctrl+n": + if m.ac.sel < len(m.ac.items)-1 { + m.ac.sel++ + m.refresh() + } + return m, nil + case "tab", "enter": + m.acceptCompletion() + return m, nil + case "esc": + m.closeAC() + return m, nil + } + } + switch msg.String() { case "ctrl+c": m.quitting = true @@ -186,7 +265,7 @@ func (m *Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { // Insert a newline into the textarea. var cmd tea.Cmd m.ta, cmd = m.ta.Update(tea.KeyMsg{Type: tea.KeyEnter}) - return m, cmd + return m, tea.Batch(cmd, m.syncAC()) case "ctrl+t": m.thinkOn = !m.thinkOn return m, nil @@ -202,9 +281,10 @@ func (m *Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, cmd } + // Normal typing — update the input, then re-evaluate @-completion. var cmd tea.Cmd m.ta, cmd = m.ta.Update(msg) - return m, cmd + return m, tea.Batch(cmd, m.syncAC()) } func (m *Model) handleEvent(ev client.Event) (tea.Model, tea.Cmd) { @@ -231,6 +311,7 @@ func (m *Model) handleEvent(ev client.Event) (tea.Model, tea.Cmd) { if i := m.cur(); i >= 0 { m.msgs[i].steps = append(m.msgs[i].steps, step{name: ev.Name, arg: argPreview(ev.Data)}) } + m.lastTool = ev.Name m.status = "running " + ev.Name case "tool_result": @@ -244,10 +325,12 @@ func (m *Model) handleEvent(ev client.Event) (tea.Model, tea.Cmd) { } } } + m.lastTool = "" case "done": m.finalize() m.busy = false + m.lastTool = "" m.status = "ready" m.sessCtxTok = ev.SessionContextTokens m.sessOutTok = ev.SessionOutputTokens @@ -261,6 +344,7 @@ func (m *Model) handleEvent(ev client.Event) (tea.Model, tea.Cmd) { } m.finalize() m.busy = false + m.lastTool = "" m.status = "error" case "approval_request": @@ -304,8 +388,10 @@ func (m *Model) submit() tea.Cmd { m.msgs = append(m.msgs, message{role: roleAsst, streaming: true}) m.curIdx = len(m.msgs) - 1 m.ta.Reset() + m.closeAC() m.busy = true m.status = "thinking" + m.runStart = time.Now() m.thinking.Reset() m.refresh() @@ -378,18 +464,13 @@ func (m *Model) render(content string) string { func (m *Model) resize(w, h int) tea.Cmd { m.width, m.height = w, h - vpHeight := h - headerHeight - inputHeight - footerHeight - if vpHeight < 3 { - vpHeight = 3 - } if !m.ready { - m.vp = viewport.New(w, vpHeight) + m.vp = viewport.New(w, 3) m.ready = true - } else { - m.vp.Width = w - m.vp.Height = vpHeight } m.ta.SetWidth(w - 4) + m.gradRule = "" // invalidate cached rule for the new width + m.relayout() wrap := w - 6 if wrap < 20 { @@ -411,6 +492,111 @@ func (m *Model) resize(w, h int) tea.Cmd { return nil } +// relayout recomputes the viewport height from the current chrome, accounting +// for the @-reference popup when it is open. +func (m *Model) relayout() { + if !m.ready { + return + } + inputH := inputHeight + if m.ac.open { + inputH += m.ac.height() + } + vpH := m.height - headerHeight - footerHeight - inputH + if vpH < 3 { + vpH = 3 + } + m.vp.Width = m.width + m.vp.Height = vpH +} + +// ── @-reference autocomplete ──────────────────────────────────────────────── + +// refRe matches a trailing @-reference token at the end of the input. +var refRe = regexp.MustCompile(`(^|\s)@([^\s@]*)$`) + +// activeRef returns the query of the trailing @-token, if the cursor is in one. +func activeRef(s string) (string, bool) { + mm := refRe.FindStringSubmatch(s) + if mm == nil { + return "", false + } + return mm[2], true +} + +// refStart returns the byte index of the '@' that begins the trailing token. +func refStart(s string) (int, bool) { + loc := refRe.FindStringSubmatchIndex(s) + if loc == nil { + return 0, false + } + return loc[4] - 1, true // group 2 start, minus the '@' +} + +// syncAC re-evaluates the input for an @-reference and kicks off a search. +func (m *Model) syncAC() tea.Cmd { + q, ok := activeRef(m.ta.Value()) + if !ok { + if m.ac.open { + m.closeAC() + } + return nil + } + if m.ac.open && q == m.ac.query { + return nil // nothing changed + } + m.ac.open = true + m.ac.loading = true + m.ac.query = q + m.ac.sel = 0 + m.ac.seq++ + seq := m.ac.seq + m.relayout() + m.refresh() + + cl := m.cl + return func() tea.Msg { + items, err := cl.Resources(q, 6) + if err != nil { + return acResultMsg{seq: seq, items: nil} + } + return acResultMsg{seq: seq, items: items} + } +} + +// acceptCompletion inserts the selected resource reference into the input. +func (m *Model) acceptCompletion() { + if len(m.ac.items) == 0 { + m.closeAC() + return + } + item := m.ac.items[m.ac.sel] + val := m.ta.Value() + if idx, ok := refStart(val); ok { + m.ta.SetValue(val[:idx] + item.ID + " ") + m.ta.CursorEnd() + } + m.closeAC() +} + +// closeAC dismisses the completion popup and restores the layout. +func (m *Model) closeAC() { + if !m.ac.open && m.ac.items == nil { + return + } + m.ac = autocomplete{seq: m.ac.seq} + m.relayout() + m.refresh() +} + +// elapsed formats the current run's wall-clock time, e.g. "3.2s". +func (m *Model) elapsed() string { + if m.runStart.IsZero() { + return "" + } + return formatDuration(time.Since(m.runStart)) +} + // argPreview extracts a short, human-friendly summary from a tool's JSON args. func argPreview(data string) string { data = strings.TrimSpace(data) @@ -446,6 +632,14 @@ func collapse(s string) string { return strings.Join(strings.Fields(s), " ") } +// formatDuration renders a short, friendly elapsed time. +func formatDuration(d time.Duration) string { + if d < time.Minute { + return fmt.Sprintf("%.1fs", d.Seconds()) + } + return fmt.Sprintf("%dm%02ds", int(d.Minutes()), int(d.Seconds())%60) +} + func truncate(s string, n int) string { r := []rune(s) if len(r) <= n { diff --git a/internal/tui/model_smoke_test.go b/internal/tui/model_smoke_test.go new file mode 100644 index 0000000..2d563d7 --- /dev/null +++ b/internal/tui/model_smoke_test.go @@ -0,0 +1,107 @@ +package tui + +import ( + "regexp" + "strings" + "testing" + "time" + + "github.com/charmbracelet/bubbles/spinner" + "github.com/charmbracelet/bubbles/textarea" + tea "github.com/charmbracelet/bubbletea" + + "github.com/BackendStack21/bodek/internal/client" +) + +// ansiRe strips terminal escape sequences so tests can assert on visible text. +// (glamour inserts SGR resets between words, which would break naive substring +// checks even though the rendered text is correct.) +var ansiRe = regexp.MustCompile(`\x1b\[[0-9;]*m`) + +func plain(s string) string { return ansiRe.ReplaceAllString(s, "") } + +// newTestModel builds a Model without a live client/TTY for rendering tests. +func newTestModel() *Model { + m := &Model{ + th: newTheme(), + ta: textarea.New(), + sp: spinner.New(), + curIdx: -1, + status: "ready", + events: make(chan client.Event, 8), + } + m.resize(100, 30) + return m +} + +// TestRenderStreamingTurn drives a full turn through handleEvent and asserts +// View renders without panicking and reflects the streamed content. +func TestRenderStreamingTurn(t *testing.T) { + m := newTestModel() + + // Simulate the user having sent a prompt. + m.msgs = append(m.msgs, + message{role: roleUser, content: "list the files"}, + message{role: roleAsst, streaming: true}, + ) + m.curIdx = 1 + m.busy = true + m.runStart = time.Now() + + feed := []client.Event{ + {Type: "session", SessionID: "s1", Model: "deepseek-v4-flash"}, + {Type: "thinking", Content: "let me check the directory"}, + {Type: "tool_call", Name: "shell", Data: `{"command":"ls -la"}`}, + {Type: "tool_result", Name: "shell", Data: "main.go\nREADME.md"}, + {Type: "token", Content: "Here are "}, + {Type: "token", Content: "the files."}, + {Type: "done", SessionContextTokens: 1200, SessionOutputTokens: 340, Latency: 2.5}, + } + for _, ev := range feed { + m.handleEvent(ev) + } + + if m.busy { + t.Error("model should not be busy after done") + } + out := plain(m.View()) + for _, want := range []string{"odek", "shell", "files.", "deepseek-v4-flash"} { + if !strings.Contains(out, want) { + t.Errorf("View missing %q", want) + } + } +} + +// TestApprovalAndAutocompleteRender ensures the approval panel and the @-popup +// render at full and narrow widths without panicking. +func TestApprovalAndAutocompleteRender(t *testing.T) { + m := newTestModel() + + m.approval = &client.Event{Type: "approval_request", Risk: "network_egress", + Name: "shell", Command: "curl https://example.com", Description: "fetch", AllowTrust: true} + if out := plain(m.View()); !strings.Contains(out, "approval required") { + t.Error("approval panel not rendered") + } + m.approval = nil + + m.ac = autocomplete{open: true, query: "cli", items: []client.Resource{ + {ID: "@internal/client/client.go", Type: "file", Label: "internal/client/client.go", Detail: "5.5 KB"}, + }} + m.relayout() + if out := plain(m.View()); !strings.Contains(out, "client.go") { + t.Error("autocomplete popup not rendered") + } + + // Narrow terminal must not panic. + m.resize(24, 12) + _ = m.View() +} + +// TestWindowSizeMsg exercises the resize path via Update. +func TestWindowSizeMsg(t *testing.T) { + m := newTestModel() + _, _ = m.Update(tea.WindowSizeMsg{Width: 80, Height: 24}) + if !m.ready { + t.Error("model not ready after WindowSizeMsg") + } +} diff --git a/internal/tui/styles.go b/internal/tui/styles.go index 4820d58..c3d0168 100644 --- a/internal/tui/styles.go +++ b/internal/tui/styles.go @@ -65,6 +65,9 @@ type theme struct { statusReady lipgloss.Style statusBusy lipgloss.Style + toolIcon lipgloss.Style + scroll lipgloss.Style + footer lipgloss.Style footerKey lipgloss.Style footerSep lipgloss.Style @@ -72,6 +75,14 @@ type theme struct { tagline lipgloss.Style tipKey lipgloss.Style tipText lipgloss.Style + + acBox lipgloss.Style + acTitle lipgloss.Style + acItem lipgloss.Style + acSel lipgloss.Style + acDim lipgloss.Style + acDetail lipgloss.Style + acIcon lipgloss.Style } func newTheme() theme { @@ -110,6 +121,9 @@ func newTheme() theme { statusReady: lipgloss.NewStyle().Foreground(colGreen), statusBusy: lipgloss.NewStyle().Foreground(colYellow), + toolIcon: lipgloss.NewStyle().Foreground(colCyan), + scroll: lipgloss.NewStyle().Foreground(colFaint), + footer: lipgloss.NewStyle().Foreground(colFaint), footerKey: lipgloss.NewStyle().Foreground(colMuted).Bold(true), footerSep: lipgloss.NewStyle().Foreground(colHairline), @@ -117,6 +131,14 @@ func newTheme() theme { tagline: lipgloss.NewStyle().Foreground(colMuted).Italic(true), tipKey: lipgloss.NewStyle().Foreground(colCyan).Bold(true), tipText: lipgloss.NewStyle().Foreground(colMuted), + + acBox: lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(colBrand).Padding(0, 1), + acTitle: lipgloss.NewStyle().Foreground(colBrand).Bold(true), + acItem: lipgloss.NewStyle().Foreground(colFg), + acSel: lipgloss.NewStyle().Foreground(colBrand2).Bold(true), + acDim: lipgloss.NewStyle().Foreground(colFaint).Italic(true), + acDetail: lipgloss.NewStyle().Foreground(colFaint), + acIcon: lipgloss.NewStyle().Foreground(colCyan), } } diff --git a/internal/tui/view.go b/internal/tui/view.go index 762d9df..2f6dcdb 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -3,6 +3,7 @@ package tui import ( "fmt" "strings" + "time" "github.com/charmbracelet/lipgloss" ) @@ -40,10 +41,11 @@ func (m *Model) header() string { if modelName == "" { modelName = "default" } - meta := th.headerMeta.Render(fmt.Sprintf("%s · sandbox %s · think %s", - th.headerKey.Render(modelName), sandbox, think)) + meta := th.headerMeta.Render(" · sandbox "+sandbox+" · think ") + + th.headerKey.Render(think) + model := th.headerKey.Render(modelName) - left := logo + th.headerMeta.Render(" "+meta) + left := logo + " " + model + meta status := m.statusBadge() tokens := th.headerMeta.Render(fmt.Sprintf("∑ ⌂ %s · ⎇ %s", @@ -55,8 +57,23 @@ func (m *Model) header() string { gap = 1 } bar := left + strings.Repeat(" ", gap) + right - rule := th.rule.Render(strings.Repeat("─", max(m.width, 1))) - return bar + "\n" + rule + return bar + "\n" + m.rule() +} + +// rule returns a full-width gradient hairline, cached per width. +func (m *Model) rule() string { + w := max(m.width, 1) + if m.gradRule == "" || m.gradRuleW != w { + m.gradRule = gradient(strings.Repeat("─", w), gradFrom, gradTo) + m.gradRuleW = w + } + return m.gradRule +} + +// engagingVerbs cycle while the model is reasoning, so the UI feels alive. +var engagingVerbs = []string{ + "thinking", "reasoning it through", "connecting the dots", + "consulting the model", "weighing the options", "planning the approach", } func (m *Model) statusBadge() string { @@ -65,9 +82,25 @@ func (m *Model) statusBadge() string { case m.disconn: return lipgloss.NewStyle().Foreground(colRed).Render("● disconnected") case m.approval != nil: - return th.statusBusy.Render("● approval required") + return th.statusBusy.Render("⚠ approval required") case m.busy: - return th.statusBusy.Render(m.sp.View() + " " + m.status) + label := m.status + switch { + case m.lastTool != "": + label = th.toolIcon.Render(toolGlyph(m.lastTool)) + " " + + th.statusBusy.Render(m.lastTool) + case label == "thinking", label == "": + // Cycle engaging verbs roughly every ~1.8s of the run. + idx := int(time.Since(m.runStart)/(1800*time.Millisecond)) % len(engagingVerbs) + label = th.statusBusy.Render(engagingVerbs[idx]) + default: + label = th.statusBusy.Render(label) + } + el := "" + if e := m.elapsed(); e != "" { + el = th.headerMeta.Render(" · " + e) + } + return th.spinner.Render(m.sp.View()) + " " + label + el default: return th.statusReady.Render("● " + m.status) } @@ -76,12 +109,17 @@ func (m *Model) statusBadge() string { // ── transcript ─────────────────────────────────────────────────────────── // refresh rebuilds the viewport content and scrolls to the latest output. +// While busy it follows the stream; when idle it preserves the reader's +// position unless they were already at the bottom. func (m *Model) refresh() { if !m.ready { return } + stick := m.busy || m.vp.AtBottom() m.vp.SetContent(m.conversation()) - m.vp.GotoBottom() + if stick { + m.vp.GotoBottom() + } } func (m *Model) conversation() string { @@ -148,11 +186,12 @@ func (m *Model) renderSteps(msg message) string { case s.done: icon = th.stepDone.Render("✓") case msg.streaming: - icon = m.sp.View() + icon = th.spinner.Render(m.sp.View()) default: icon = th.stepRun.Render("▸") } - line := icon + " " + th.stepName.Render(s.name) + glyph := th.toolIcon.Render(toolGlyph(s.name)) + line := icon + " " + glyph + " " + th.stepName.Render(s.name) if s.arg != "" { line += th.stepArg.Render(" " + s.arg) } @@ -179,7 +218,54 @@ func (m *Model) inputArea() string { if m.approval != nil { return m.approvalPanel() } - return m.th.inputBox.Width(m.width - 2).Render(m.ta.View()) + box := m.th.inputBox.Width(m.width - 2).Render(m.ta.View()) + if m.ac.open { + return m.acPopup() + "\n" + box + } + return box +} + +// acPopup renders the @-reference completion box. Its height must match +// autocomplete.height() so the layout math stays exact. +func (m *Model) acPopup() string { + th := m.th + // Inner content width inside the box (border + padding = 4 columns). + innerW := m.width - 6 + if innerW < 12 { + innerW = 12 + } + + title := th.acTitle.Render("@ reference") + if hint := " ↑↓ select · ⇥ insert · esc cancel"; 11+lipgloss.Width(hint) <= innerW { + title += th.acDim.Render(hint) + } + + var rows []string + switch { + case m.ac.loading && len(m.ac.items) == 0: + rows = append(rows, th.acDim.Render(m.sp.View()+" searching…")) + case len(m.ac.items) == 0: + rows = append(rows, th.acDim.Render("no matches for @"+m.ac.query)) + default: + for i, it := range m.ac.items { + // Truncate in plain text first so styled rows never wrap. + budget := innerW - 4 // prefix(2) + icon(1) + space(1) + lab := truncate(it.Label, budget) + rest := budget - lipgloss.Width(lab) + det := "" + if it.Detail != "" && rest > 6 { + det = th.acDetail.Render(truncate(" "+it.Detail, rest)) + } + icon := th.acIcon.Render(resourceGlyph(it.Type)) + prefix, lbl := " ", th.acItem.Render(lab) + if i == m.ac.sel { + prefix, lbl = th.acSel.Render("› "), th.acSel.Render(lab) + } + rows = append(rows, prefix+icon+" "+lbl+det) + } + } + body := title + "\n" + strings.Join(rows, "\n") + return th.acBox.Width(m.width - 2).Render(body) } func (m *Model) approvalPanel() string { @@ -228,9 +314,16 @@ func (m *Model) footer() string { } left := " " + strings.Join(keys, sep) - right := "" + var segs []string if m.lastLatency > 0 { - right = th.footer.Render(fmt.Sprintf("%.1fs ", m.lastLatency)) + segs = append(segs, th.footer.Render(fmt.Sprintf("⚡ %.1fs", m.lastLatency))) + } + if !m.vp.AtBottom() { + segs = append(segs, th.scroll.Render(fmt.Sprintf("↕ %d%%", int(m.vp.ScrollPercent()*100)))) + } + right := "" + if len(segs) > 0 { + right = strings.Join(segs, th.footerSep.Render(" · ")) + " " } gap := m.width - lipgloss.Width(left) - lipgloss.Width(right) if gap < 1 { From 71d1fd301966817ccb3dc4dc7a0efe308de9a065 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 18 Jun 2026 08:48:48 +0000 Subject: [PATCH 3/5] feat: sessions, models, cancel, sandbox UX + full API coverage, CI/release MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surface the rest of odek serve's API in the TUI and harden the project. TUI / endpoints (all odek serve APIs now used): - /api/sessions + /api/sessions/{id}: session browser (^R) to resume, replay transcript, and delete past conversations - /api/models: model switcher (^O) - /api/cancel: Esc aborts the running turn - per-session auth tokens persisted in ~/.bodek/sessions.json so resume/ cancel/delete work across runs (internal/tokens) - sandbox status shown in the header (🛡 sandboxed / ⚠ host access) - context-aware progress messages derived from the running tool/command (🧪 running tests, 📖 reading X, 🚀 pushing) with live elapsed timer Tests & quality: - comprehensive unit + integration tests against an in-process odek serve stand-in; internal-package coverage ~99% (client 100, tui 99, tokens 98, server 95 — remainder is unreachable OS-error handling) - golangci-lint v2 config (clean), Makefile test/cover/lint targets - GitHub Actions: CI (build, vet, race tests + coverage, lint) and a GoReleaser release workflow on version tags - README updated with new features, key bindings, badges, and dev docs Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_012rWqu7pktbd2ejfNw3a7Vf --- .github/workflows/ci.yml | 57 ++++ .github/workflows/release.yml | 33 ++ .golangci.yml | 18 ++ .goreleaser.yaml | 51 ++++ Makefile | 19 +- README.md | 39 ++- internal/client/client.go | 39 ++- internal/client/client_ws_test.go | 227 ++++++++++++++ internal/client/rest.go | 129 ++++++++ internal/client/rest_unit_test.go | 99 ++++++ internal/server/server.go | 12 +- internal/server/server_internal_test.go | 82 +++++ internal/server/server_test.go | 100 ++++++ internal/tokens/tokens.go | 107 +++++++ internal/tokens/tokens_test.go | 153 ++++++++++ internal/tui/coverage_test.go | 179 +++++++++++ internal/tui/dispatch_test.go | 123 ++++++++ internal/tui/final_gaps_test.go | 188 ++++++++++++ internal/tui/gaps_test.go | 47 +++ internal/tui/integration_test.go | 360 ++++++++++++++++++++++ internal/tui/model.go | 70 ++++- internal/tui/panels.go | 387 ++++++++++++++++++++++++ internal/tui/progress.go | 128 ++++++++ internal/tui/progress_test.go | 127 ++++++++ internal/tui/view.go | 83 +++-- 25 files changed, 2802 insertions(+), 55 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/release.yml create mode 100644 .golangci.yml create mode 100644 .goreleaser.yaml create mode 100644 internal/client/client_ws_test.go create mode 100644 internal/client/rest.go create mode 100644 internal/client/rest_unit_test.go create mode 100644 internal/server/server_internal_test.go create mode 100644 internal/server/server_test.go create mode 100644 internal/tokens/tokens.go create mode 100644 internal/tokens/tokens_test.go create mode 100644 internal/tui/coverage_test.go create mode 100644 internal/tui/dispatch_test.go create mode 100644 internal/tui/final_gaps_test.go create mode 100644 internal/tui/gaps_test.go create mode 100644 internal/tui/integration_test.go create mode 100644 internal/tui/panels.go create mode 100644 internal/tui/progress.go create mode 100644 internal/tui/progress_test.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..9c8a20e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,57 @@ +name: CI + +on: + push: + branches: [main, master] + pull_request: + +permissions: + contents: read + +jobs: + build-test: + name: Build & Test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - name: Build + run: go build ./... + + - name: Vet + run: go vet ./... + + - name: Test (race + coverage) + run: go test -race -coverpkg=./internal/... -coverprofile=coverage.out ./internal/... + + - name: Coverage summary + run: go tool cover -func=coverage.out | tail -1 + + - name: Upload coverage profile + uses: actions/upload-artifact@v4 + with: + name: coverage + path: coverage.out + + lint: + name: Lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - name: golangci-lint + uses: golangci/golangci-lint-action@v6 + with: + version: v2.5.0 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..39c6111 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,33 @@ +name: Release + +on: + push: + tags: + - 'v*' + +permissions: + contents: write + +jobs: + goreleaser: + name: GoReleaser + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - name: Run GoReleaser + uses: goreleaser/goreleaser-action@v6 + with: + distribution: goreleaser + version: '~> v2' + args: release --clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..4db6de7 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,18 @@ +version: "2" + +run: + timeout: 5m + +linters: + default: standard # errcheck, govet, ineffassign, staticcheck, unused + exclusions: + rules: + # Test helpers freely ignore errors on HTTP test handlers and Close(). + - path: _test\.go + linters: + - errcheck + +formatters: + enable: + - gofmt + - goimports diff --git a/.goreleaser.yaml b/.goreleaser.yaml new file mode 100644 index 0000000..28ce0d6 --- /dev/null +++ b/.goreleaser.yaml @@ -0,0 +1,51 @@ +version: 2 + +project_name: bodek + +before: + hooks: + - go mod tidy + +builds: + - id: bodek + main: ./cmd/bodek + binary: bodek + env: + - CGO_ENABLED=0 + goos: + - linux + - darwin + - windows + goarch: + - amd64 + - arm64 + ldflags: + - -s -w + +archives: + - id: bodek + formats: [tar.gz] + name_template: >- + {{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }} + format_overrides: + - goos: windows + formats: [zip] + +checksum: + name_template: checksums.txt + +snapshot: + version_template: '{{ incpatch .Version }}-next' + +changelog: + sort: asc + filters: + exclude: + - '^docs:' + - '^test:' + - '^ci:' + - '^chore:' + +release: + draft: false + prerelease: auto diff --git a/Makefile b/Makefile index 9f51fb8..976b6e2 100644 --- a/Makefile +++ b/Makefile @@ -2,7 +2,7 @@ BINARY := bodek PKG := ./cmd/bodek GOBIN ?= $(shell go env GOPATH)/bin -.PHONY: all build install run fmt vet tidy clean +.PHONY: all build install run fmt vet lint test cover tidy clean all: build @@ -27,6 +27,23 @@ fmt: vet: go vet ./... +## lint: run golangci-lint if available +lint: + @if command -v golangci-lint >/dev/null 2>&1; then \ + golangci-lint run ./...; \ + else \ + echo "golangci-lint not installed; skipping (see https://golangci-lint.run)"; \ + fi + +## test: run the race-enabled test suite +test: + go test -race ./... + +## cover: print internal-package coverage +cover: + go test -coverpkg=./internal/... -coverprofile=coverage.out ./internal/... + go tool cover -func=coverage.out | tail -1 + ## tidy: tidy module dependencies tidy: go mod tidy diff --git a/README.md b/README.md index 5aab86c..aa1a0fa 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,10 @@ # bodek +[![CI](https://github.com/BackendStack21/bodek/actions/workflows/ci.yml/badge.svg)](https://github.com/BackendStack21/bodek/actions/workflows/ci.yml) +[![Release](https://github.com/BackendStack21/bodek/actions/workflows/release.yml/badge.svg)](https://github.com/BackendStack21/bodek/actions/workflows/release.yml) +[![Go Reference](https://pkg.go.dev/badge/github.com/BackendStack21/bodek.svg)](https://pkg.go.dev/github.com/BackendStack21/bodek) +[![Go Report Card](https://goreportcard.com/badge/github.com/BackendStack21/bodek)](https://goreportcard.com/report/github.com/BackendStack21/bodek) + **A beautiful [Bubble Tea](https://github.com/charmbracelet/bubbletea) terminal interface for the [odek](https://github.com/BackendStack21/odek) agent.** ``` @@ -78,9 +83,12 @@ by `odek serve` from its usual chain — `~/.odek/config.json` → `./odek.json` |-----|--------| | `⏎` | Send the prompt | | `@` | Open file/session reference completion (see below) | -| `^J` | Insert a newline in the input | +| `^R` | Browse & resume saved sessions | +| `^O` | Switch the model | | `^T` | Toggle extended thinking for the next turn | +| `^J` | Insert a newline in the input | | `^L` | Clear the conversation | +| `Esc` | Cancel the running turn | | `PgUp` / `PgDn` / wheel | Scroll the transcript | | `^C` | Quit | @@ -123,6 +131,14 @@ When the agent requests approval for a dangerous operation, answer inline: - **Live reasoning** — the model's pre-tool thinking streams in dimmed text, with a running elapsed timer and cycling status while it works. - **`@` autocomplete** — a live, navigable popup of files and sessions. +- **Context-aware progress** — while the agent works, the status badge shows + what it's actually doing (`🧪 running tests`, `📖 reading client.go`, + `🚀 pushing`) with a live elapsed timer. +- **Session browser** (`^R`) — resume, replay, or delete past conversations. +- **Model switcher** (`^O`) — change the model for the next turn. +- **Cancellation** (`Esc`) — abort a running turn via odek's cancel API. +- **Sandbox aware** — the header shows `🛡 sandboxed` or `⚠ host access`; pass + `--sandbox` to run tool calls inside odek's Docker isolation. - **Telemetry** — session token totals and last-turn latency in the chrome. - **Fluent by default** — gradient wordmark and hairline, smooth braille spinner, smart autoscroll that never yanks you while you read history, and a @@ -137,18 +153,35 @@ When the agent requests approval for a dangerous operation, answer inline: ```bash make build # → bin/bodek make run # build and launch +make test # go test -race ./... +make cover # coverage report for internal packages +make lint # golangci-lint (if installed) make vet make tidy ``` +Continuous integration runs build, `go vet`, `golangci-lint`, and the race- +enabled test suite on every push (see [`.github/workflows`](.github/workflows)). +Tagged releases (`vX.Y.Z`) are built and published automatically by +[GoReleaser](https://goreleaser.com). + Project layout: | Path | Responsibility | |------|----------------| | `cmd/bodek` | CLI entry point: flags, lifecycle, wiring | | `internal/server` | Launch / attach to `odek serve`, resolve the auth token | -| `internal/client` | odek serve WebSocket protocol (transport + event decoding) | -| `internal/tui` | The Bubble Tea model, update loop, and view | +| `internal/client` | odek serve WebSocket protocol (transport + REST + decoding) | +| `internal/tokens` | Local persistence of per-session auth tokens | +| `internal/tui` | The Bubble Tea model, update loop, panels, and view | + +### Architecture & testing + +bodek is a pure client, so it is highly testable: the WebSocket protocol, REST +endpoints, token store, and the full Bubble Tea update/view loop are exercised +by unit and integration tests against an in-process `odek serve` stand-in. +Internal-package statement coverage is **~99%** (client 100%, tui 99%, tokens +98%, server 95% — the remainder is unreachable OS-error handling). --- diff --git a/internal/client/client.go b/internal/client/client.go index 42ca784..c483ff0 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -34,6 +34,7 @@ type Event struct { // session SessionID string `json:"session_id"` + AuthToken string `json:"auth_token"` Model string `json:"model"` Sandbox bool `json:"sandbox"` @@ -117,7 +118,7 @@ func (c *Client) Resources(query string, limit int) ([]Resource, error) { if err != nil { return nil, err } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("resources: status %s", resp.Status) } @@ -147,22 +148,32 @@ func (c *Client) readLoop() { // prompt is the client→server prompt message. type prompt struct { - Type string `json:"type"` - Content string `json:"content"` - Thinking string `json:"thinking,omitempty"` - Model string `json:"model,omitempty"` + Type string `json:"type"` + Content string `json:"content"` + Thinking string `json:"thinking,omitempty"` + Model string `json:"model,omitempty"` + SessionID string `json:"session_id,omitempty"` + AuthToken string `json:"auth_token,omitempty"` } -// SendPrompt submits a task. thinking is "enabled" to force reasoning for this -// turn, or "" for the server default. model switches the active model when set. -// Session continuity is automatic: the server keeps one conversation per -// connection. -func (c *Client) SendPrompt(content, thinking, model string) error { +// PromptOpts are optional parameters for a prompt turn. +type PromptOpts struct { + Thinking string // "enabled" to force reasoning this turn, "" for default + Model string // switch the active model when set + SessionID string // resume/continue a specific session + AuthToken string // session-scoped token, required when SessionID is set +} + +// SendPrompt submits a task. Session continuity is automatic on a single +// connection; SessionID+AuthToken resume a saved conversation. +func (c *Client) SendPrompt(content string, opts PromptOpts) error { return ws.JSON.Send(c.conn, prompt{ - Type: "prompt", - Content: content, - Thinking: thinking, - Model: model, + Type: "prompt", + Content: content, + Thinking: opts.Thinking, + Model: opts.Model, + SessionID: opts.SessionID, + AuthToken: opts.AuthToken, }) } diff --git a/internal/client/client_ws_test.go b/internal/client/client_ws_test.go new file mode 100644 index 0000000..2243b88 --- /dev/null +++ b/internal/client/client_ws_test.go @@ -0,0 +1,227 @@ +package client + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + ws "golang.org/x/net/websocket" +) + +// newTestServer spins an httptest server that speaks the bits of the odek serve +// protocol bodek depends on: a /ws endpoint plus the REST APIs. +func newTestServer(t *testing.T, mux *http.ServeMux) (*Client, *httptest.Server) { + t.Helper() + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + cl, err := Dial(wsURL+"/ws", srv.URL, srv.URL, "test-token") + if err != nil { + t.Fatalf("Dial: %v", err) + } + t.Cleanup(func() { cl.Close() }) + return cl, srv +} + +func TestDialPromptStream(t *testing.T) { + mux := http.NewServeMux() + mux.Handle("/ws", ws.Handler(func(c *ws.Conn) { + // Verify the auth header was forwarded. + if got := c.Request().Header.Get("X-Odek-Ws-Token"); got != "test-token" { + return + } + var data []byte + if err := ws.Message.Receive(c, &data); err != nil { + return + } + var p prompt + _ = json.Unmarshal(data, &p) + _ = ws.JSON.Send(c, map[string]any{"type": "session", "session_id": "s1", "auth_token": "a1", "model": "m"}) + _ = ws.Message.Send(c, `{"type":"token","content":"`+p.Content+`"}`) + _ = ws.Message.Send(c, `{"type":"done","latency":1.0}`) + })) + cl, _ := newTestServer(t, mux) + + if err := cl.SendPrompt("hello", PromptOpts{Thinking: "enabled", Model: "x"}); err != nil { + t.Fatalf("SendPrompt: %v", err) + } + + want := []string{"session", "token", "done"} + for i, w := range want { + select { + case ev := <-cl.Events: + if ev.Type != w { + t.Errorf("event %d = %q, want %q", i, ev.Type, w) + } + if w == "token" && ev.Content != "hello" { + t.Errorf("token content = %q", ev.Content) + } + case <-time.After(3 * time.Second): + t.Fatalf("timeout waiting for %q", w) + } + } +} + +func TestSendApprovalAndDisconnect(t *testing.T) { + mux := http.NewServeMux() + mux.Handle("/ws", ws.Handler(func(c *ws.Conn) { + var data []byte + if err := ws.Message.Receive(c, &data); err != nil { + return + } + var a approval + _ = json.Unmarshal(data, &a) + _ = ws.JSON.Send(c, map[string]any{"type": "ack", "name": a.Action}) + // Close → client should surface EventDisconnected. + })) + cl, _ := newTestServer(t, mux) + + if err := cl.SendApproval("apr-1", "approve"); err != nil { + t.Fatalf("SendApproval: %v", err) + } + var sawAck, sawDisc bool + for !sawDisc { + select { + case ev, ok := <-cl.Events: + if !ok { + t.Fatal("channel closed without EventDisconnected") + } + switch ev.Type { + case "ack": + sawAck = true + if ev.Name != "approve" { + t.Errorf("ack name = %q", ev.Name) + } + case EventDisconnected: + sawDisc = true + } + case <-time.After(3 * time.Second): + t.Fatal("timeout") + } + } + if !sawAck { + t.Error("never saw ack event") + } +} + +func TestMalformedFrameIgnored(t *testing.T) { + mux := http.NewServeMux() + mux.Handle("/ws", ws.Handler(func(c *ws.Conn) { + var data []byte + _ = ws.Message.Receive(c, &data) + _ = ws.Message.Send(c, `not json`) // ignored + _ = ws.Message.Send(c, `{"type":"token","content":"ok"}`) + })) + cl, _ := newTestServer(t, mux) + _ = cl.SendPrompt("x", PromptOpts{}) + select { + case ev := <-cl.Events: + if ev.Type != "token" { + t.Errorf("expected token after malformed frame, got %q", ev.Type) + } + case <-time.After(3 * time.Second): + t.Fatal("timeout") + } +} + +func TestDialError(t *testing.T) { + if _, err := Dial("ws://127.0.0.1:1/ws", "http://127.0.0.1:1", "http://127.0.0.1:1", "t"); err == nil { + t.Error("expected dial error to unreachable server") + } +} + +func TestCloseNilConn(t *testing.T) { + c := &Client{} + if err := c.Close(); err != nil { + t.Errorf("Close on nil conn = %v", err) + } +} + +func TestRESTEndpoints(t *testing.T) { + mux := http.NewServeMux() + mux.Handle("/ws", ws.Handler(func(c *ws.Conn) { _, _ = c.Write(nil) })) + mux.HandleFunc("/api/sessions", func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode([]Session{{ID: "s1", Task: "do it", Turns: 2}}) + }) + mux.HandleFunc("/api/sessions/", func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + if r.Header.Get("X-Session-Token") != "tok" { + w.WriteHeader(http.StatusUnauthorized) + return + } + w.Header().Set("X-Session-Token", "tok") + json.NewEncoder(w).Encode(Session{ID: "s1", Messages: []SessionMessage{{Role: "user", Content: "hi"}}}) + case http.MethodDelete: + w.WriteHeader(http.StatusNoContent) + } + }) + mux.HandleFunc("/api/models", func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode([]ModelInfo{{ID: "m1", Current: true, Description: "model one"}}) + }) + mux.HandleFunc("/api/resources", func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("q") != "main" { + t.Errorf("query = %q", r.URL.Query().Get("q")) + } + json.NewEncoder(w).Encode([]Resource{{ID: "@main.go", Type: "file", Label: "main.go"}}) + }) + mux.HandleFunc("/api/cancel", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.Query().Get("session_id") == "" { + w.WriteHeader(http.StatusBadRequest) + return + } + w.WriteHeader(http.StatusNoContent) + }) + cl, _ := newTestServer(t, mux) + + sess, err := cl.Sessions() + if err != nil || len(sess) != 1 || sess[0].Task != "do it" { + t.Fatalf("Sessions = %+v, %v", sess, err) + } + detail, tok, err := cl.SessionDetail("s1", "tok") + if err != nil || tok != "tok" || len(detail.Messages) != 1 { + t.Fatalf("SessionDetail = %+v tok=%q err=%v", detail, tok, err) + } + if err := cl.DeleteSession("s1", "tok"); err != nil { + t.Fatalf("DeleteSession: %v", err) + } + models, err := cl.Models() + if err != nil || len(models) != 1 || !models[0].Current { + t.Fatalf("Models = %+v, %v", models, err) + } + res, err := cl.Resources("main", 6) + if err != nil || len(res) != 1 || res[0].ID != "@main.go" { + t.Fatalf("Resources = %+v, %v", res, err) + } + if err := cl.Cancel("s1", "tok"); err != nil { + t.Fatalf("Cancel: %v", err) + } +} + +func TestRESTErrorStatuses(t *testing.T) { + mux := http.NewServeMux() + mux.Handle("/ws", ws.Handler(func(c *ws.Conn) {})) + mux.HandleFunc("/api/sessions/", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + }) + mux.HandleFunc("/api/cancel", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + }) + cl, _ := newTestServer(t, mux) + + if _, _, err := cl.SessionDetail("s1", ""); err == nil { + t.Error("expected SessionDetail error on 401") + } + if err := cl.DeleteSession("s1", ""); err == nil { + t.Error("expected DeleteSession error on 401") + } + if err := cl.Cancel("s1", ""); err == nil { + t.Error("expected Cancel error on 500") + } + if _, err := cl.Sessions(); err == nil { + t.Error("expected Sessions error (404)") + } +} diff --git a/internal/client/rest.go b/internal/client/rest.go new file mode 100644 index 0000000..22e3035 --- /dev/null +++ b/internal/client/rest.go @@ -0,0 +1,129 @@ +package client + +import ( + "encoding/json" + "fmt" + "net/http" + "net/url" + "time" +) + +// SessionMessage is one turn in a saved session transcript. +type SessionMessage struct { + Role string `json:"role"` + Content string `json:"content"` +} + +// Session is a saved conversation, as returned by the session API. +type Session struct { + ID string `json:"id"` + Model string `json:"model"` + Turns int `json:"turns"` + Task string `json:"task"` + Sandbox bool `json:"sandbox"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Messages []SessionMessage `json:"messages"` +} + +// ModelInfo describes an available model from the models API. +type ModelInfo struct { + ID string `json:"id"` + MaxContext int `json:"max_context"` + Description string `json:"description"` + Current bool `json:"current"` +} + +// Sessions lists recent saved sessions (auth tokens are not included). +func (c *Client) Sessions() ([]Session, error) { + var out []Session + if err := c.getJSON(c.baseURL+"/api/sessions", "", &out); err != nil { + return nil, err + } + return out, nil +} + +// SessionDetail loads a full session transcript. Pass the session's known +// auth token (empty is accepted for sessions that have never been tokened). It +// returns the effective token from the X-Session-Token response header, falling +// back to the token passed in. +func (c *Client) SessionDetail(id, token string) (Session, string, error) { + var s Session + resp, err := c.do(http.MethodGet, c.baseURL+"/api/sessions/"+url.PathEscape(id), token) + if err != nil { + return s, "", err + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + return s, "", fmt.Errorf("session: status %s", resp.Status) + } + if err := json.NewDecoder(resp.Body).Decode(&s); err != nil { + return s, "", err + } + eff := resp.Header.Get("X-Session-Token") + if eff == "" { + eff = token + } + return s, eff, nil +} + +// DeleteSession removes a saved session (requires its auth token). +func (c *Client) DeleteSession(id, token string) error { + resp, err := c.do(http.MethodDelete, c.baseURL+"/api/sessions/"+url.PathEscape(id), token) + if err != nil { + return err + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK { + return fmt.Errorf("delete: status %s", resp.Status) + } + return nil +} + +// Models lists models advertised by the server. +func (c *Client) Models() ([]ModelInfo, error) { + var out []ModelInfo + if err := c.getJSON(c.baseURL+"/api/models", "", &out); err != nil { + return nil, err + } + return out, nil +} + +// Cancel aborts the in-flight prompt for a session (requires its auth token). +func (c *Client) Cancel(sessionID, token string) error { + u := c.baseURL + "/api/cancel?session_id=" + url.QueryEscape(sessionID) + resp, err := c.do(http.MethodPost, u, token) + if err != nil { + return err + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK { + return fmt.Errorf("cancel: status %s", resp.Status) + } + return nil +} + +// ── low-level helpers ──────────────────────────────────────────────────────── + +func (c *Client) do(method, u, sessionToken string) (*http.Response, error) { + req, err := http.NewRequest(method, u, nil) + if err != nil { + return nil, err + } + if sessionToken != "" { + req.Header.Set("X-Session-Token", sessionToken) + } + return c.http.Do(req) +} + +func (c *Client) getJSON(u, sessionToken string, dst interface{}) error { + resp, err := c.do(http.MethodGet, u, sessionToken) + if err != nil { + return err + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("status %s", resp.Status) + } + return json.NewDecoder(resp.Body).Decode(dst) +} diff --git a/internal/client/rest_unit_test.go b/internal/client/rest_unit_test.go new file mode 100644 index 0000000..2c732e4 --- /dev/null +++ b/internal/client/rest_unit_test.go @@ -0,0 +1,99 @@ +package client + +import ( + "net/http" + "net/http/httptest" + "testing" + "time" +) + +// unreachableClient points at a closed port so every request errors. +func unreachableClient() *Client { + return &Client{baseURL: "http://127.0.0.1:1", http: &http.Client{Timeout: 200 * time.Millisecond}} +} + +func TestRESTRequestErrors(t *testing.T) { + c := unreachableClient() + if _, err := c.Sessions(); err == nil { + t.Error("Sessions should error") + } + if _, err := c.Models(); err == nil { + t.Error("Models should error") + } + if _, err := c.Resources("q", 5); err == nil { + t.Error("Resources should error") + } + if _, _, err := c.SessionDetail("id", "tok"); err == nil { + t.Error("SessionDetail should error") + } + if err := c.DeleteSession("id", "tok"); err == nil { + t.Error("DeleteSession should error") + } + if err := c.Cancel("id", "tok"); err == nil { + t.Error("Cancel should error") + } +} + +func TestRESTBadJSON(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("{not json")) + })) + defer srv.Close() + c := &Client{baseURL: srv.URL, http: &http.Client{Timeout: time.Second}} + if _, err := c.Sessions(); err == nil { + t.Error("Sessions should error on bad JSON") + } + if _, _, err := c.SessionDetail("id", "tok"); err == nil { + t.Error("SessionDetail should error on bad JSON") + } +} + +func TestResourcesBadJSON(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("{not json")) + })) + defer srv.Close() + c := &Client{baseURL: srv.URL, http: &http.Client{Timeout: time.Second}} + if _, err := c.Resources("q", 5); err == nil { + t.Error("Resources should error on bad JSON") + } +} + +func TestResourcesNon200(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer srv.Close() + c := &Client{baseURL: srv.URL, http: &http.Client{Timeout: time.Second}} + if _, err := c.Resources("q", 5); err == nil { + t.Error("Resources should error on 500") + } +} + +func TestSessionDetailFallbackToken(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // No X-Session-Token header → SessionDetail falls back to the passed token. + w.Write([]byte(`{"id":"s1"}`)) + })) + defer srv.Close() + c := &Client{baseURL: srv.URL, http: &http.Client{Timeout: time.Second}} + _, tok, err := c.SessionDetail("s1", "passed-token") + if err != nil || tok != "passed-token" { + t.Fatalf("fallback token = %q, err=%v", tok, err) + } +} + +func TestDialBadURL(t *testing.T) { + if _, err := Dial("://bad", "://bad", "x", "t"); err == nil { + t.Error("Dial should error on a malformed ws URL") + } +} + +func TestDoRequestBuildError(t *testing.T) { + // A control character in the base URL makes http.NewRequest fail, exercising + // the do() error path. + c := &Client{baseURL: "http://\x7f", http: &http.Client{Timeout: time.Second}} + if _, err := c.Sessions(); err == nil { + t.Error("expected request-build error") + } +} diff --git a/internal/server/server.go b/internal/server/server.go index ac9c36b..2fe1027 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -17,6 +17,10 @@ import ( const wsTokenCookie = "odek_ws_token" +// readyTimeout bounds how long Connect waits for the server to come up. It is a +// variable so tests can shorten it. +var readyTimeout = 30 * time.Second + // Conn holds everything needed to talk to an odek serve instance. type Conn struct { BaseURL string // http://127.0.0.1:port @@ -71,7 +75,7 @@ func Connect(opts Options) (*Conn, error) { } } - if err := waitReady(c.BaseURL, 30*time.Second); err != nil { + if err := waitReady(c.BaseURL, readyTimeout); err != nil { c.Stop() return nil, fmt.Errorf("odek serve did not become ready: %w", err) } @@ -135,7 +139,7 @@ func freePort() (int, error) { if err != nil { return 0, err } - defer l.Close() + defer func() { _ = l.Close() }() return l.Addr().(*net.TCPAddr).Port, nil } @@ -149,7 +153,7 @@ func waitReady(baseURL string, timeout time.Duration) error { resp, err := client.Do(req) cancel() if err == nil { - resp.Body.Close() + _ = resp.Body.Close() if resp.StatusCode < 500 { return nil } @@ -167,7 +171,7 @@ func fetchToken(baseURL string) (string, error) { if err != nil { return "", err } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() for _, ck := range resp.Cookies() { if ck.Name == wsTokenCookie && ck.Value != "" { return ck.Value, nil diff --git a/internal/server/server_internal_test.go b/internal/server/server_internal_test.go new file mode 100644 index 0000000..2f258f9 --- /dev/null +++ b/internal/server/server_internal_test.go @@ -0,0 +1,82 @@ +package server + +import ( + "net/http" + "net/http/httptest" + "os/exec" + "testing" + "time" +) + +// TestSpawnAndStop exercises the spawn + Stop lifecycle using a harmless +// short-lived binary in place of odek. +func TestSpawnAndStop(t *testing.T) { + bin, err := exec.LookPath("true") + if err != nil { + t.Skip("no 'true' binary available") + } + for _, sandbox := range []bool{false, true} { + c := &Conn{} + if err := c.spawn(Options{Bin: bin, Sandbox: sandbox}, "127.0.0.1:0"); err != nil { + t.Fatalf("spawn(sandbox=%v): %v", sandbox, err) + } + if c.proc == nil { + t.Fatal("proc not set after spawn") + } + c.Stop() // process already exited or exits on signal + } +} + +func TestStopInterruptsLongProcess(t *testing.T) { + bin, err := exec.LookPath("sleep") + if err != nil { + t.Skip("no 'sleep' binary") + } + c := &Conn{proc: exec.Command(bin, "30")} + if err := c.proc.Start(); err != nil { + t.Fatalf("start: %v", err) + } + done := make(chan struct{}) + go func() { c.Stop(); close(done) }() + select { + case <-done: + case <-time.After(10 * time.Second): + t.Fatal("Stop did not return") + } +} + +func TestSpawnDefaultBinMissing(t *testing.T) { + // Empty Bin defaults to "odek"; absent from PATH here → LookPath error. + c := &Conn{} + if err := c.spawn(Options{Bin: ""}, "127.0.0.1:0"); err == nil { + t.Error("expected default-bin lookup to fail") + } +} + +func TestConnectSpawnNotReady(t *testing.T) { + bin, err := exec.LookPath("sleep") + if err != nil { + t.Skip("no 'sleep' binary") + } + old := readyTimeout + readyTimeout = 250 * time.Millisecond + defer func() { readyTimeout = old }() + + // 'sleep serve --addr …' starts but never serves HTTP → waitReady fails → + // Connect tears the process down and returns an error. + if _, err := Connect(Options{Bin: bin}); err == nil { + t.Error("expected Connect to fail when the server never becomes ready") + } +} + +func TestConnectFetchTokenFailure(t *testing.T) { + // Server is ready but never issues the token cookie → Connect fails at + // fetchToken and tears down. + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + if _, err := Connect(Options{URL: srv.URL}); err == nil { + t.Error("expected Connect to fail without a token cookie") + } +} diff --git a/internal/server/server_test.go b/internal/server/server_test.go new file mode 100644 index 0000000..69b11c5 --- /dev/null +++ b/internal/server/server_test.go @@ -0,0 +1,100 @@ +package server + +import ( + "net/http" + "net/http/httptest" + "testing" + "time" +) + +func TestFreePort(t *testing.T) { + p, err := freePort() + if err != nil { + t.Fatalf("freePort: %v", err) + } + if p <= 0 || p > 65535 { + t.Errorf("freePort = %d, out of range", p) + } +} + +func TestWaitReady(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + if err := waitReady(srv.URL, 3*time.Second); err != nil { + t.Errorf("waitReady: %v", err) + } +} + +func TestWaitReadyTimeout(t *testing.T) { + // An unreachable address should time out quickly. + if err := waitReady("http://127.0.0.1:1", 300*time.Millisecond); err == nil { + t.Error("expected waitReady timeout") + } +} + +func TestFetchToken(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.SetCookie(w, &http.Cookie{Name: wsTokenCookie, Value: "secret-token"}) + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + tok, err := fetchToken(srv.URL) + if err != nil || tok != "secret-token" { + t.Fatalf("fetchToken = %q, %v", tok, err) + } +} + +func TestFetchTokenMissingCookie(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + if _, err := fetchToken(srv.URL); err == nil { + t.Error("expected error when cookie missing") + } +} + +func TestFetchTokenRequestError(t *testing.T) { + if _, err := fetchToken("http://127.0.0.1:1"); err == nil { + t.Error("expected fetchToken error to unreachable host") + } +} + +func TestConnectViaURL(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.SetCookie(w, &http.Cookie{Name: wsTokenCookie, Value: "tok"}) + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + conn, err := Connect(Options{URL: srv.URL}) + if err != nil { + t.Fatalf("Connect: %v", err) + } + if conn.Token != "tok" { + t.Errorf("Token = %q", conn.Token) + } + if conn.BaseURL != srv.URL { + t.Errorf("BaseURL = %q, want %q", conn.BaseURL, srv.URL) + } + wantWS := "ws" + srv.URL[len("http"):] + "/ws" + if conn.WSURL != wantWS { + t.Errorf("WSURL = %q, want %q", conn.WSURL, wantWS) + } + conn.Stop() // no spawned process — must be a no-op +} + +func TestConnectSpawnMissingBinary(t *testing.T) { + _, err := Connect(Options{Bin: "definitely-not-a-real-binary-xyz"}) + if err == nil { + t.Error("expected error for missing odek binary") + } +} + +func TestStopNilSafe(t *testing.T) { + var c *Conn + c.Stop() // must not panic + (&Conn{}).Stop() +} diff --git a/internal/tokens/tokens.go b/internal/tokens/tokens.go new file mode 100644 index 0000000..7ddbef1 --- /dev/null +++ b/internal/tokens/tokens.go @@ -0,0 +1,107 @@ +// Package tokens persists per-session auth tokens locally so bodek can resume, +// cancel, and delete sessions across runs. +// +// odek serve issues a session-scoped secret (the WS `session` event's +// auth_token) and requires it on the cancel/detail/delete endpoints. The Web +// UI keeps these in localStorage; bodek keeps them in ~/.bodek/sessions.json. +// Persistence is best-effort — a Store with no writable path still works as an +// in-memory cache for the current run. +package tokens + +import ( + "encoding/json" + "os" + "path/filepath" + "sync" +) + +// Store is a concurrency-safe session-id → token map with best-effort +// persistence. +type Store struct { + mu sync.Mutex + m map[string]string + path string +} + +// Open loads the token store from ~/.bodek/sessions.json. It never fails: on +// any error it returns an in-memory-only store. +func Open() *Store { + s := &Store{m: map[string]string{}} + home, err := os.UserHomeDir() + if err != nil { + return s + } + s.path = filepath.Join(home, ".bodek", "sessions.json") + if data, err := os.ReadFile(s.path); err == nil { + _ = json.Unmarshal(data, &s.m) + } + return s +} + +// Get returns the stored token for a session, or "" if unknown. +func (s *Store) Get(id string) string { + if s == nil { + return "" + } + s.mu.Lock() + defer s.mu.Unlock() + return s.m[id] +} + +// Set records a session's token and persists the store (best-effort). +func (s *Store) Set(id, token string) { + if s == nil || id == "" || token == "" { + return + } + s.mu.Lock() + if s.m[id] == token { + s.mu.Unlock() + return // no change; skip the disk write + } + s.m[id] = token + snapshot := make(map[string]string, len(s.m)) + for k, v := range s.m { + snapshot[k] = v + } + path := s.path + s.mu.Unlock() + persist(path, snapshot) +} + +// Delete removes a session's token and persists the store (best-effort). +func (s *Store) Delete(id string) { + if s == nil || id == "" { + return + } + s.mu.Lock() + if _, ok := s.m[id]; !ok { + s.mu.Unlock() + return + } + delete(s.m, id) + snapshot := make(map[string]string, len(s.m)) + for k, v := range s.m { + snapshot[k] = v + } + path := s.path + s.mu.Unlock() + persist(path, snapshot) +} + +func persist(path string, m map[string]string) { + if path == "" { + return + } + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return + } + data, err := json.MarshalIndent(m, "", " ") + if err != nil { + return + } + tmp := path + ".tmp" + if err := os.WriteFile(tmp, data, 0o600); err != nil { + return + } + _ = os.Rename(tmp, path) +} diff --git a/internal/tokens/tokens_test.go b/internal/tokens/tokens_test.go new file mode 100644 index 0000000..3511de7 --- /dev/null +++ b/internal/tokens/tokens_test.go @@ -0,0 +1,153 @@ +package tokens + +import ( + "os" + "path/filepath" + "testing" +) + +func TestStorePersistence(t *testing.T) { + dir := t.TempDir() + t.Setenv("HOME", dir) + // On some platforms UserHomeDir consults other vars; set them too. + t.Setenv("USERPROFILE", dir) + + s := Open() + if got := s.Get("missing"); got != "" { + t.Errorf("Get(missing) = %q, want empty", got) + } + s.Set("sess-1", "tok-1") + s.Set("sess-2", "tok-2") + if got := s.Get("sess-1"); got != "tok-1" { + t.Errorf("Get(sess-1) = %q", got) + } + + // A fresh Store should load what was persisted. + s2 := Open() + if got := s2.Get("sess-2"); got != "tok-2" { + t.Errorf("reloaded Get(sess-2) = %q, want tok-2", got) + } + + // Delete persists too. + s2.Delete("sess-1") + s3 := Open() + if got := s3.Get("sess-1"); got != "" { + t.Errorf("after delete Get(sess-1) = %q, want empty", got) + } + if got := s3.Get("sess-2"); got != "tok-2" { + t.Errorf("after delete Get(sess-2) = %q, want tok-2", got) + } + + if _, err := os.Stat(filepath.Join(dir, ".bodek", "sessions.json")); err != nil { + t.Errorf("store file not written: %v", err) + } +} + +func TestStoreNilSafe(t *testing.T) { + var s *Store + if got := s.Get("x"); got != "" { + t.Errorf("nil Get = %q", got) + } + s.Set("a", "b") // must not panic + s.Delete("a") // must not panic +} + +func TestStoreIgnoresEmpty(t *testing.T) { + s := &Store{m: map[string]string{}} + s.Set("", "tok") // empty id ignored + s.Set("id", "") // empty token ignored + if len(s.m) != 0 { + t.Errorf("empty inputs were stored: %v", s.m) + } + s.Delete("nope") // missing id is a no-op +} + +func TestSetNoChangeSkipsWrite(t *testing.T) { + s := &Store{m: map[string]string{"id": "tok"}} + s.Set("id", "tok") // identical value — should be a no-op + if s.m["id"] != "tok" { + t.Error("value changed unexpectedly") + } +} + +func TestNoPathPersistNoop(t *testing.T) { + // A store with no path keeps values in memory and never touches disk. + s := &Store{m: map[string]string{}} + s.Set("id", "tok") + if s.Get("id") != "tok" { + t.Error("in-memory set failed") + } + s.Delete("id") + if s.Get("id") != "" { + t.Error("in-memory delete failed") + } +} + +func TestOpenNoHome(t *testing.T) { + t.Setenv("HOME", "") + t.Setenv("USERPROFILE", "") + s := Open() // UserHomeDir errors → in-memory store with empty path + s.Set("k", "v") + if s.Get("k") != "v" { + t.Error("in-memory store should still work without a home dir") + } +} + +func TestPersistMkdirError(t *testing.T) { + dir := t.TempDir() + // Make a regular file, then use it as if it were a directory in the path — + // MkdirAll must fail, exercising persist's error branch. + blocker := filepath.Join(dir, "afile") + if err := os.WriteFile(blocker, []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + s := &Store{m: map[string]string{}, path: filepath.Join(blocker, "sub", "sessions.json")} + s.Set("k", "v") // persist fails internally; value still cached in memory + if s.Get("k") != "v" { + t.Error("value should remain in memory even if persist fails") + } +} + +func TestPersistWriteAndRenameErrors(t *testing.T) { + dir := t.TempDir() + + // WriteFile error: the temp target path is an existing directory. + base := filepath.Join(dir, "a") + if err := os.Mkdir(base+".tmp", 0o755); err != nil { + t.Fatal(err) + } + s := &Store{m: map[string]string{}, path: base} + s.Set("k", "v") // WriteFile(base+".tmp") fails; value stays in memory + if s.Get("k") != "v" { + t.Error("value should remain in memory after write failure") + } + + // Rename error: the destination path is an existing directory. + d2 := filepath.Join(dir, "d") + if err := os.Mkdir(d2, 0o755); err != nil { + t.Fatal(err) + } + s2 := &Store{m: map[string]string{}, path: d2} + s2.Set("k", "v") // WriteFile ok, Rename onto a directory fails +} + +func TestOpenWithCorruptFile(t *testing.T) { + dir := t.TempDir() + t.Setenv("HOME", dir) + t.Setenv("USERPROFILE", dir) + // Pre-seed a corrupt store file; Open must tolerate it. + if err := os.MkdirAll(filepath.Join(dir, ".bodek"), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, ".bodek", "sessions.json"), []byte("{bad"), 0o600); err != nil { + t.Fatal(err) + } + s := Open() + if s.Get("anything") != "" { + t.Error("corrupt file should yield empty store") + } + s.Set("k", "v") // should still persist fine + if Open().Get("k") != "v" { + t.Error("set after corrupt-open did not persist") + } +} diff --git a/internal/tui/coverage_test.go b/internal/tui/coverage_test.go new file mode 100644 index 0000000..7fa8a47 --- /dev/null +++ b/internal/tui/coverage_test.go @@ -0,0 +1,179 @@ +package tui + +import ( + "strings" + "testing" + "time" + + "github.com/BackendStack21/bodek/internal/client" +) + +func TestListen(t *testing.T) { + ch := make(chan client.Event, 1) + ch <- client.Event{Type: "hello"} + if msg := listen(ch)(); eventMsg(msg.(eventMsg)).Type != "hello" { + t.Errorf("listen value = %+v", msg) + } + closed := make(chan client.Event) + close(closed) + if msg := listen(closed)(); client.Event(msg.(eventMsg)).Type != client.EventDisconnected { + t.Errorf("listen closed = %+v", msg) + } +} + +func TestGlyphsAllBranches(t *testing.T) { + for _, n := range []string{"shell", "bash", "write_file", "patch", "read_file", "list_dir", "search_files", "web_search", "browser", "http_batch", "delegate_tasks", "memory", "vision", "transcribe", "unknown_x"} { + if toolGlyph(n) == "" { + t.Errorf("empty glyph for %q", n) + } + } + for _, ty := range []string{"session", "skill", "file"} { + if resourceGlyph(ty) == "" { + t.Errorf("empty resource glyph for %q", ty) + } + } +} + +func TestStatusBadgeStates(t *testing.T) { + m := wired(t) + m.runStart = time.Now() + + m.busy, m.lastTool, m.lastArg = true, "shell", "go test" + if !strings.Contains(plain(m.statusBadge()), "tests") { + t.Error("tool status badge missing") + } + m.lastTool = "" + m.status = "responding" + if !strings.Contains(plain(m.statusBadge()), "composing") { + t.Error("responding badge missing") + } + m.status = "thinking" + if plain(m.statusBadge()) == "" { + t.Error("thinking badge empty") + } + m.busy = false + m.approval = &client.Event{Type: "approval_request"} + if !strings.Contains(plain(m.statusBadge()), "approval") { + t.Error("approval badge missing") + } + m.approval = nil + m.disconn = true + if !strings.Contains(plain(m.statusBadge()), "disconnected") { + t.Error("disconnected badge missing") + } +} + +func TestNotReadyView(t *testing.T) { + m := &Model{th: newTheme(), curIdx: -1} + if !strings.Contains(m.View(), "starting") { + t.Error("not-ready view should say starting") + } + m.refresh() // no-op when not ready — must not panic +} + +func TestAddNoteRingBuffer(t *testing.T) { + m := wired(t) + for i := 0; i < 10; i++ { + m.addNote("note") + } + if len(m.notices) > 6 { + t.Errorf("notices not capped: %d", len(m.notices)) + } +} + +func TestArgPreviewFallbacks(t *testing.T) { + if got := argPreview(`{"foo":"bar"}`); got != "bar" { + t.Errorf("argPreview value-join = %q", got) + } + if got := argPreview(`{"n":123}`); got != "" { + t.Errorf("argPreview non-string = %q", got) + } +} + +func TestRenderFallbacks(t *testing.T) { + m := wired(t) + if m.render("") != "" { + t.Error("render empty should be empty") + } + m.glam = nil + if m.render("# hi") != "# hi" { + t.Error("render without glamour should pass through") + } +} + +func TestAcceptCompletionNoRef(t *testing.T) { + m := wired(t) + m.ac.items = []client.Resource{{ID: "@x", Label: "x"}} + m.ac.open = true + m.ta.SetValue("no at sign here") + m.acceptCompletion() // refStart fails → just closes + if m.ac.open { + t.Error("accept should close the popup") + } + // Empty items → closes. + m.ac.items = nil + m.ac.open = true + m.acceptCompletion() + if m.ac.open { + t.Error("accept with no items should close") + } +} + +func TestPanelLenAndKeys(t *testing.T) { + m := wired(t) + if m.panelLen() != 0 { + t.Error("panelLen with no panel should be 0") + } + // 'd' on the models panel is a no-op (delete only applies to sessions). + m.panel = panelModels + m.models = []client.ModelInfo{{ID: "a"}} + m.Update(key("d")) + // vim-style nav + q to close. + m.Update(key("j")) + m.Update(key("k")) + m.Update(key("q")) + if m.panel != panelNone { + t.Error("q should close panel") + } +} + +func TestSubmitGuards(t *testing.T) { + m := wired(t) + m.busy = true + if m.submit() != nil { + t.Error("submit while busy should be nil") + } + m.busy = false + m.ta.SetValue(" ") + if m.submit() != nil { + t.Error("submit with blank text should be nil") + } + m.disconn = true + m.ta.SetValue("hi") + if m.submit() != nil { + t.Error("submit while disconnected should be nil") + } +} + +func TestTinyHelpers(t *testing.T) { + if orDash("") != "—" || orDash("x") != "x" { + t.Error("orDash") + } + if max(1, 2) != 2 || max(5, 3) != 5 { + t.Error("max") + } + if pad("ab", 4) != "ab " || pad("abcd", 2) != "abcd" { + t.Errorf("pad: %q / %q", pad("ab", 4), pad("abcd", 2)) + } + if human(0) != "0" { + t.Error("human zero") + } +} + +func TestQuitKeys(t *testing.T) { + m := wired(t) + m.Update(key("ctrl+c")) + if !m.quitting { + t.Error("ctrl+c should set quitting") + } +} diff --git a/internal/tui/dispatch_test.go b/internal/tui/dispatch_test.go new file mode 100644 index 0000000..441835e --- /dev/null +++ b/internal/tui/dispatch_test.go @@ -0,0 +1,123 @@ +package tui + +import ( + "testing" + "time" + + "github.com/charmbracelet/bubbles/spinner" + + "github.com/BackendStack21/bodek/internal/client" +) + +func TestUpdateMessageDispatch(t *testing.T) { + m := wired(t) + + m.Update(sessionsMsg{items: []client.Session{{ID: "s1"}}}) + m.Update(modelsMsg{items: []client.ModelInfo{{ID: "m1"}}}) + m.Update(sessionDetailMsg{sess: client.Session{ID: "s1"}, token: "a1"}) + m.Update(sessionDeletedMsg{id: "s1"}) + m.Update(cancelDoneMsg{}) + m.Update(cancelDoneMsg{err: errTest{}}) + + // Spinner tick while busy triggers a refresh path. + m.busy = true + m.runStart = time.Now() + m.Update(spinner.TickMsg{}) + m.busy = false + m.ac.loading = true + m.Update(spinner.TickMsg{}) +} + +func TestCurOutOfRange(t *testing.T) { + m := wired(t) + m.curIdx = 99 // no messages + if m.cur() != -1 { + t.Error("cur should be -1 when index is out of range") + } + // token event with no current message must be a safe no-op. + m.handleEvent(client.Event{Type: "token", Content: "x"}) +} + +func TestAutocompleteNavKeys(t *testing.T) { + m := wired(t) + m.ac.open = true + m.ac.items = []client.Resource{{ID: "@a", Label: "a"}, {ID: "@b", Label: "b"}} + m.Update(key("ctrl+n")) // down + if m.ac.sel != 1 { + t.Errorf("ctrl+n sel = %d", m.ac.sel) + } + m.Update(key("ctrl+p")) // up + if m.ac.sel != 0 { + t.Errorf("ctrl+p sel = %d", m.ac.sel) + } + m.Update(key("up")) // already at top — no move + m.Update(key("tab")) + if m.ac.open { + t.Error("tab should accept and close") + } + // esc closes an open popup. + m.ac.open = true + m.ac.items = []client.Resource{{ID: "@a", Label: "a"}} + m.Update(key("esc")) + if m.ac.open { + t.Error("esc should close popup") + } +} + +func TestPanelKeyBoundsAndQuit(t *testing.T) { + m := wired(t) + m.panel = panelSessions + m.sessions = []client.Session{{ID: "s1"}} + m.Update(key("up")) // at top, no move + m.Update(key("down")) // at bottom (only 1), no move + if m.panelSel != 0 { + t.Errorf("panelSel = %d", m.panelSel) + } + m.Update(key("ctrl+c")) + if !m.quitting { + t.Error("ctrl+c in panel should quit") + } +} + +func TestCancelRunGuards(t *testing.T) { + m := wired(t) + // Not busy → nil. + if m.cancelRun() != nil { + t.Error("cancelRun not busy should be nil") + } + // Busy but no session id → nil. + m.busy = true + if m.cancelRun() != nil { + t.Error("cancelRun without session should be nil") + } +} + +func TestDeleteSelectedGuard(t *testing.T) { + m := wired(t) + m.panel = panelSessions + m.sessions = nil + m.panelSel = 5 + if m.deleteSelected() != nil { + t.Error("deleteSelected out of range should be nil") + } +} + +func TestPanelSelectGuards(t *testing.T) { + m := wired(t) + // panelSelect with selection beyond range is a safe no-op. + m.panel = panelSessions + m.sessions = nil + m.panelSel = 3 + if m.panelSelect() != nil { + t.Error("panelSelect sessions out-of-range should be nil") + } + m.panel = panelModels + m.models = nil + if m.panelSelect() != nil { + t.Error("panelSelect models out-of-range should be nil") + } + m.panel = panelNone + if m.panelSelect() != nil { + t.Error("panelSelect with no panel should be nil") + } +} diff --git a/internal/tui/final_gaps_test.go b/internal/tui/final_gaps_test.go new file mode 100644 index 0000000..89de0c6 --- /dev/null +++ b/internal/tui/final_gaps_test.go @@ -0,0 +1,188 @@ +package tui + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" + ws "golang.org/x/net/websocket" + + "github.com/BackendStack21/bodek/internal/client" +) + +func TestUpdateEventMsgAndDefault(t *testing.T) { + m := wired(t) + // eventMsg routed through Update. + m.msgs = append(m.msgs, message{role: roleAsst, streaming: true}) + m.curIdx = 0 + m.Update(eventMsg(client.Event{Type: "token", Content: "hi"})) + if m.msgs[0].content != "hi" { + t.Errorf("token via Update not applied: %q", m.msgs[0].content) + } + // An unrecognised message type falls through to the textarea. + m.Update(12345) +} + +func TestHeaderThinkAndSandbox(t *testing.T) { + m := wired(t) + m.thinkOn = true + m.sandbox = true + if !strings.Contains(plain(m.header()), "sandboxed") { + t.Error("sandbox shield missing from header") + } +} + +func TestRenderNoteAndStepDefaultIcon(t *testing.T) { + m := wired(t) + // roleNote rendering. + out := m.renderMessage(message{role: roleNote, content: "a note"}) + if !strings.Contains(plain(out), "a note") { + t.Error("note message not rendered") + } + // A finalized assistant message with an unfinished step uses the ▸ icon. + msg := message{role: roleAsst, content: "done", steps: []step{{name: "shell", done: false}}} + out = m.renderMessage(msg) + if !strings.Contains(plain(out), "shell") { + t.Error("step not rendered") + } +} + +func TestAcPopupLoadingAndEmpty(t *testing.T) { + m := wired(t) + m.ac.open = true + m.ac.loading = true + if !strings.Contains(plain(m.acPopup()), "searching") { + t.Error("loading popup missing") + } + m.ac.loading = false + m.ac.items = nil + m.ac.query = "zzz" + if !strings.Contains(plain(m.acPopup()), "no matches") { + t.Error("empty popup missing") + } +} + +func TestFooterBusyAndRenderPanelSmall(t *testing.T) { + m := wired(t) + m.busy = true + if !strings.Contains(plain(m.footer()), "cancel") { + t.Error("busy footer should show cancel hint") + } + // renderPanel with a tiny height exercises the visible<1 clamp. + m.panel = panelSessions + m.panelMsg = "loading…" + m.sessions = []client.Session{{ID: "s1", Task: ""}} // untitled branch + _ = m.renderPanel(80, 3) +} + +func TestWindowRowsStartClamp(t *testing.T) { + rows := []string{"a", "b", "c", "d", "e"} + got := windowRows(rows, 0, 3) // sel-n/2 = -1 → clamp to 0 + if len(got) != 3 || got[0] != "a" { + t.Errorf("windowRows start-clamp = %v", got) + } +} + +func TestPanelNavMovement(t *testing.T) { + m := wired(t) + m.panel = panelSessions + m.sessions = []client.Session{{ID: "a"}, {ID: "b"}, {ID: "c"}} + m.Update(key("down")) + if m.panelSel != 1 { + t.Errorf("down → sel=%d", m.panelSel) + } + m.Update(key("up")) + if m.panelSel != 0 { + t.Errorf("up → sel=%d", m.panelSel) + } +} + +func TestHandleSessionDeletedDecrement(t *testing.T) { + m := wired(t) + m.panel = panelSessions + m.sessions = []client.Session{{ID: "a"}, {ID: "b"}} + m.panelSel = 1 + m.handleSessionDeleted(sessionDeletedMsg{id: "b"}) + if len(m.sessions) != 1 || m.panelSel != 0 { + t.Errorf("after delete: n=%d sel=%d", len(m.sessions), m.panelSel) + } +} + +func TestArgPreviewKnownKeyNonString(t *testing.T) { + if got := argPreview(`{"command":123}`); got != "" { + t.Errorf("argPreview non-string command = %q", got) + } +} + +func TestResizeReRendersFinalized(t *testing.T) { + m := wired(t) + m.msgs = append(m.msgs, message{role: roleAsst, content: "# hi", streaming: false}) + m.resize(120, 40) // triggers the finalized re-render loop + if m.msgs[0].rendered == "" { + t.Error("finalized assistant message should be re-rendered on resize") + } +} + +func TestSubmitAndAnswerSendErrors(t *testing.T) { + m := wired(t) + m.cl.Close() // force subsequent socket writes to fail + + m.ta.SetValue("hello") + if msg := exec(m.submit()); msg == nil { + t.Error("submit cmd should yield an errMsg when the socket is closed") + } else if _, ok := msg.(errMsg); !ok { + t.Errorf("submit error = %T, want errMsg", msg) + } + + m.approval = &client.Event{ID: "x"} + if msg := exec(m.answer("approve")); msg == nil { + t.Error("answer cmd should yield an errMsg when the socket is closed") + } +} + +// downModel builds a model whose server is then shut down, so REST calls fail. +func downModel(t *testing.T) *Model { + t.Helper() + t.Setenv("HOME", t.TempDir()) + mux := http.NewServeMux() + mux.Handle("/ws", ws.Handler(func(c *ws.Conn) { + for { + var d []byte + if ws.Message.Receive(c, &d) != nil { + return + } + } + })) + srv := httptest.NewServer(mux) + cl, err := client.Dial("ws"+strings.TrimPrefix(srv.URL, "http")+"/ws", srv.URL, srv.URL, "t") + if err != nil { + t.Fatalf("Dial: %v", err) + } + t.Cleanup(func() { cl.Close() }) + m := New(cl, Options{}) + m.Update(tea.WindowSizeMsg{Width: 100, Height: 30}) + srv.Close() // REST + ws now unavailable + return m +} + +func TestSyncACResourcesError(t *testing.T) { + m := downModel(t) + m.ta.SetValue("see @x") + msg := exec(m.syncAC()) + if r, ok := msg.(acResultMsg); !ok || r.items != nil { + t.Errorf("syncAC against a down server = %#v", msg) + } +} + +func TestDeleteSelectedDetailError(t *testing.T) { + m := downModel(t) + m.panel = panelSessions + m.sessions = []client.Session{{ID: "s1"}} + m.panelSel = 0 + msg := exec(m.deleteSelected()) + if d, ok := msg.(sessionDeletedMsg); !ok || d.err == nil { + t.Errorf("deleteSelected should error against a down server: %#v", msg) + } +} diff --git a/internal/tui/gaps_test.go b/internal/tui/gaps_test.go new file mode 100644 index 0000000..b4ccb65 --- /dev/null +++ b/internal/tui/gaps_test.go @@ -0,0 +1,47 @@ +package tui + +import ( + "testing" + + "github.com/BackendStack21/bodek/internal/client" +) + +func TestApprovalUnmatchedAndNoTrust(t *testing.T) { + m := wired(t) + // AllowTrust=false: pressing "t" must NOT resolve the approval. + m.approval = &client.Event{Type: "approval_request", AllowTrust: false} + m.Update(key("t")) + if m.approval == nil { + t.Error("'t' without AllowTrust should not clear approval") + } + // An unrelated key falls through to a no-op. + m.Update(key("z")) + if m.approval == nil { + t.Error("unrelated key should leave approval pending") + } +} + +func TestSyncACUnchangedQuery(t *testing.T) { + m := wired(t) + m.ta.SetValue("see @doc") + m.ac.open = true + m.ac.query = "doc" + if cmd := m.syncAC(); cmd != nil { + t.Error("syncAC with an unchanged query should return nil") + } + // No active ref while popup open → closeAC path. + m.ta.SetValue("plain text") + m.ac.open = true + if cmd := m.syncAC(); cmd != nil { + t.Error("syncAC with no ref should return nil and close") + } + if m.ac.open { + t.Error("syncAC should have closed the popup") + } +} + +func TestArgPreviewURLKey(t *testing.T) { + if got := argPreview(`{"url":"http://x"}`); got != "http://x" { + t.Errorf("argPreview url = %q", got) + } +} diff --git a/internal/tui/integration_test.go b/internal/tui/integration_test.go new file mode 100644 index 0000000..d6b405b --- /dev/null +++ b/internal/tui/integration_test.go @@ -0,0 +1,360 @@ +package tui + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + tea "github.com/charmbracelet/bubbletea" + ws "golang.org/x/net/websocket" + + "github.com/BackendStack21/bodek/internal/client" +) + +// wired builds a Model backed by a live in-process odek-serve stand-in. +func wired(t *testing.T) *Model { + t.Helper() + t.Setenv("HOME", t.TempDir()) + + mux := http.NewServeMux() + mux.Handle("/ws", ws.Handler(func(c *ws.Conn) { + for { + var d []byte + if err := ws.Message.Receive(c, &d); err != nil { + return + } + _ = ws.JSON.Send(c, map[string]any{"type": "session", "session_id": "s1", "auth_token": "a1", "model": "m"}) + _ = ws.Message.Send(c, `{"type":"done","latency":1}`) + } + })) + mux.HandleFunc("/api/sessions", func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode([]client.Session{{ID: "s1", Task: "first task", Turns: 1, UpdatedAt: time.Now()}}) + }) + mux.HandleFunc("/api/sessions/", func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodDelete { + w.WriteHeader(http.StatusNoContent) + return + } + w.Header().Set("X-Session-Token", "a1") + json.NewEncoder(w).Encode(client.Session{ + ID: "s1", Model: "m", + Messages: []client.SessionMessage{ + {Role: "user", Content: "hi"}, + {Role: "assistant", Content: "hello there"}, + {Role: "assistant", Content: ""}, // skipped (empty) + }, + }) + }) + mux.HandleFunc("/api/models", func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode([]client.ModelInfo{{ID: "m1", Description: "one", Current: true}}) + }) + mux.HandleFunc("/api/resources", func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode([]client.Resource{{ID: "@main.go", Type: "file", Label: "main.go", Detail: "1 KB"}}) + }) + mux.HandleFunc("/api/cancel", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNoContent) + }) + + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + cl, err := client.Dial("ws"+strings.TrimPrefix(srv.URL, "http")+"/ws", srv.URL, srv.URL, "tok") + if err != nil { + t.Fatalf("Dial: %v", err) + } + t.Cleanup(func() { cl.Close() }) + + m := New(cl, Options{Model: "m"}) + m.Update(tea.WindowSizeMsg{Width: 100, Height: 30}) + return m +} + +func exec(cmd tea.Cmd) tea.Msg { + if cmd == nil { + return nil + } + return cmd() +} + +func key(s string) tea.KeyMsg { + switch s { + case "enter": + return tea.KeyMsg{Type: tea.KeyEnter} + case "esc": + return tea.KeyMsg{Type: tea.KeyEsc} + case "tab": + return tea.KeyMsg{Type: tea.KeyTab} + case "up": + return tea.KeyMsg{Type: tea.KeyUp} + case "down": + return tea.KeyMsg{Type: tea.KeyDown} + case "pgup": + return tea.KeyMsg{Type: tea.KeyPgUp} + case "ctrl+c": + return tea.KeyMsg{Type: tea.KeyCtrlC} + case "ctrl+r": + return tea.KeyMsg{Type: tea.KeyCtrlR} + case "ctrl+o": + return tea.KeyMsg{Type: tea.KeyCtrlO} + case "ctrl+t": + return tea.KeyMsg{Type: tea.KeyCtrlT} + case "ctrl+l": + return tea.KeyMsg{Type: tea.KeyCtrlL} + case "ctrl+j": + return tea.KeyMsg{Type: tea.KeyCtrlJ} + default: + return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(s)} + } +} + +func TestInitAndBasicKeys(t *testing.T) { + m := wired(t) + if cmd := m.Init(); cmd == nil { + t.Error("Init returned nil cmd") + } + // Typing a normal rune. + m.Update(key("h")) + // Toggle thinking. + m.Update(key("ctrl+t")) + if !m.thinkOn { + t.Error("ctrl+t did not enable thinking") + } + m.Update(key("ctrl+t")) + // Clear (not busy). + m.msgs = append(m.msgs, message{role: roleUser, content: "x"}) + m.Update(key("ctrl+l")) + if len(m.msgs) != 0 { + t.Error("ctrl+l did not clear") + } + // Scroll + newline + spinner tick + mouse. + m.Update(key("pgup")) + m.Update(key("ctrl+j")) + m.Update(tea.MouseMsg{}) +} + +func TestSubmitFlow(t *testing.T) { + m := wired(t) + m.ta.SetValue("do something") + _, cmd := m.Update(key("enter")) + if !m.busy { + t.Fatal("model not busy after submit") + } + if len(m.msgs) != 2 { + t.Fatalf("expected user+assistant messages, got %d", len(m.msgs)) + } + exec(cmd) // sends the prompt over the socket + + // Drain the server's response events into the model. + deadline := time.After(3 * time.Second) + for m.busy { + select { + case ev := <-m.cl.Events: + m.handleEvent(ev) + case <-deadline: + t.Fatal("did not receive done") + } + } + if m.sessionID != "s1" || m.authToken != "a1" { + t.Errorf("session/token not captured: %q/%q", m.sessionID, m.authToken) + } +} + +func TestEventHandling(t *testing.T) { + m := wired(t) + m.msgs = append(m.msgs, message{role: roleUser, content: "q"}, message{role: roleAsst, streaming: true}) + m.curIdx = 1 + m.busy = true + m.runStart = time.Now() + + evs := []client.Event{ + {Type: "thinking", Content: "hmm"}, + {Type: "tool_call", Name: "shell", Data: `{"command":"go test"}`}, + {Type: "tool_result", Name: "shell", Data: "ok"}, + {Type: "token", Content: "answer"}, + {Type: "skill_event", SubType: "loaded", SkillName: "x"}, + {Type: "memory_event", SubType: "merge", Target: "user"}, + {Type: "agent_signal", SubType: "trim", Detail: "ctx"}, + {Type: "subagent_log", SubType: "start", Name: "t0"}, + {Type: "done", SessionContextTokens: 100, SessionOutputTokens: 20, Latency: 2}, + } + for _, ev := range evs { + m.handleEvent(ev) + } + if m.busy { + t.Error("still busy after done") + } + if len(m.notices) == 0 { + t.Error("expected notices from engine events") + } + _ = m.View() + + // Error event path. + m.msgs = append(m.msgs, message{role: roleAsst, streaming: true}) + m.curIdx = len(m.msgs) - 1 + m.busy = true + m.handleEvent(client.Event{Type: "error", Message: "boom"}) + if m.busy { + t.Error("error did not clear busy") + } + + // Disconnect. + m.handleEvent(client.Event{Type: client.EventDisconnected}) + if !m.disconn { + t.Error("disconnect not recorded") + } + _ = m.View() +} + +func TestApprovalFlow(t *testing.T) { + m := wired(t) + m.busy = true + m.handleEvent(client.Event{Type: "approval_request", ID: "apr-1", Risk: "shell_exec", + Name: "shell", Command: "rm x", Description: "delete", AllowTrust: true}) + if m.approval == nil { + t.Fatal("approval not set") + } + out := m.View() + if !strings.Contains(plain(out), "approval required") { + t.Error("approval panel missing") + } + // Trust, then a fresh approval and deny, then approve. + for _, action := range []string{"t", "d", "a"} { + m.handleEvent(client.Event{Type: "approval_request", ID: "id", AllowTrust: true}) + _, cmd := m.Update(key(action)) + exec(cmd) + if m.approval != nil { + t.Errorf("approval not cleared after %q", action) + } + } + // ctrl+c during approval quits. + m.handleEvent(client.Event{Type: "approval_request", ID: "id"}) + _, cmd := m.Update(key("ctrl+c")) + _ = cmd +} + +func TestAutocompleteFlow(t *testing.T) { + m := wired(t) + m.ta.SetValue("explain @m") + exec(m.syncAC()) // fires the search; deliver result synchronously + // The result arrives as acResultMsg via the cmd return. + msg := m.syncAC() + _ = msg + // Simulate the resource result. + m.Update(acResultMsg{seq: m.ac.seq, items: []client.Resource{{ID: "@main.go", Type: "file", Label: "main.go"}}}) + if !m.ac.open { + t.Fatal("autocomplete not open") + } + out := plain(m.View()) + if !strings.Contains(out, "main.go") { + t.Error("popup missing item") + } + m.Update(key("down")) + m.Update(key("up")) + m.Update(key("enter")) // accept + if m.ac.open { + t.Error("autocomplete should close after accept") + } + if !strings.Contains(m.ta.Value(), "@main.go") { + t.Errorf("reference not inserted: %q", m.ta.Value()) + } + // Stale result is ignored. + m.Update(acResultMsg{seq: -999, items: nil}) + // Esc closes an open popup. + m.ta.SetValue("@x") + exec(m.syncAC()) + m.Update(acResultMsg{seq: m.ac.seq, items: nil}) + m.Update(key("esc")) +} + +func TestSessionsPanel(t *testing.T) { + m := wired(t) + // Open sessions: exec the cmd, deliver the result. + cmd := m.openSessions() + m.Update(exec(cmd)) + if m.panel != panelSessions || len(m.sessions) != 1 { + t.Fatalf("sessions panel state: panel=%d n=%d", m.panel, len(m.sessions)) + } + _ = plain(m.View()) + + // Navigate and resume. + m.Update(key("down")) + m.Update(key("up")) + _, rcmd := m.Update(key("enter")) // resumeSession + m.Update(exec(rcmd)) // sessionDetailMsg + if m.sessionID != "s1" { + t.Errorf("resume did not set session: %q", m.sessionID) + } + if len(m.msgs) == 0 { + t.Error("resume did not replay transcript") + } + + // Reopen and delete. + m.Update(exec(m.openSessions())) + _, dcmd := m.Update(key("d")) + m.Update(exec(dcmd)) + if len(m.sessions) != 0 { + t.Errorf("delete did not remove session: %d", len(m.sessions)) + } + m.Update(key("esc")) // close + if m.panel != panelNone { + t.Error("panel not closed") + } +} + +func TestModelsPanel(t *testing.T) { + m := wired(t) + m.Update(exec(m.openModels())) + if m.panel != panelModels || len(m.models) != 1 { + t.Fatalf("models panel: panel=%d n=%d", m.panel, len(m.models)) + } + _ = plain(m.View()) + m.Update(key("enter")) // select + if m.pendModel != "m1" { + t.Errorf("model not selected: %q", m.pendModel) + } +} + +func TestCancelFlow(t *testing.T) { + m := wired(t) + m.busy = true + m.sessionID = "s1" + m.authToken = "a1" + _, cmd := m.Update(key("esc")) + if msg := exec(cmd); msg != nil { + if cd, ok := msg.(cancelDoneMsg); ok && cd.err != nil { + t.Errorf("cancel error: %v", cd.err) + } + } + // cancelDoneMsg with error path. + m.Update(cancelDoneMsg{err: errTest{}}) +} + +type errTest struct{} + +func (errTest) Error() string { return "x" } + +func TestErrMsgAndPanelErrors(t *testing.T) { + m := wired(t) + m.Update(errMsg{err: errTest{}}) + // Panel async error branches. + m.handleSessionsMsg(sessionsMsg{err: errTest{}}) + m.handleModelsMsg(modelsMsg{err: errTest{}}) + m.handleSessionDetail(sessionDetailMsg{err: errTest{}}) + m.handleSessionDeleted(sessionDeletedMsg{id: "s1", err: errTest{}}) + // Empty-result branches. + m.handleSessionsMsg(sessionsMsg{items: nil}) + m.handleModelsMsg(modelsMsg{items: nil}) +} + +func TestElapsed(t *testing.T) { + m := wired(t) + if m.elapsed() != "" { + t.Error("elapsed should be empty before a run") + } + m.runStart = time.Now().Add(-2 * time.Second) + if m.elapsed() == "" { + t.Error("elapsed should be non-empty during a run") + } +} diff --git a/internal/tui/model.go b/internal/tui/model.go index d9af093..f6154af 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -14,6 +14,7 @@ import ( "github.com/charmbracelet/glamour" "github.com/BackendStack21/bodek/internal/client" + "github.com/BackendStack21/bodek/internal/tokens" ) // role identifies who authored a conversation entry. @@ -55,6 +56,7 @@ type Model struct { events <-chan client.Event opts Options th theme + tokens *tokens.Store width, height int ready bool @@ -70,6 +72,7 @@ type Model struct { thinking strings.Builder runStart time.Time lastTool string + lastArg string approval *client.Event // pending approval, nil when none ac autocomplete // @-reference completion state @@ -77,8 +80,16 @@ type Model struct { model string sandbox bool sessionID string + authToken string // session-scoped token (for cancel / resume) + pendModel string // model to apply on the next prompt thinkOn bool + panel panelMode + sessions []client.Session + models []client.ModelInfo + panelSel int + panelMsg string // status/error line inside a panel + sessCtxTok int sessOutTok int lastLatency float64 @@ -147,6 +158,7 @@ func New(cl *client.Client, opts Options) *Model { events: cl.Events, opts: opts, th: th, + tokens: tokens.Open(), ta: ta, sp: sp, curIdx: -1, @@ -197,6 +209,30 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.refresh() return m, nil + case sessionsMsg: + m.handleSessionsMsg(msg) + m.refresh() + return m, nil + + case modelsMsg: + m.handleModelsMsg(msg) + m.refresh() + return m, nil + + case sessionDetailMsg: + m.handleSessionDetail(msg) + return m, nil + + case sessionDeletedMsg: + return m, m.handleSessionDeleted(msg) + + case cancelDoneMsg: + if msg.err != nil { + m.addNote("cancel failed: " + msg.err.Error()) + m.refresh() + } + return m, nil + case eventMsg: return m.handleEvent(client.Event(msg)) @@ -231,6 +267,11 @@ func (m *Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil } + // A full-area panel (sessions / models) captures the keyboard while open. + if m.panel != panelNone { + return m.handlePanelKey(msg) + } + // The @-reference popup captures navigation keys while open. if m.ac.open { switch msg.String() { @@ -259,6 +300,15 @@ func (m *Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case "ctrl+c": m.quitting = true return m, tea.Quit + case "esc": + if m.busy { + return m, m.cancelRun() + } + return m, nil + case "ctrl+r": + return m, m.openSessions() + case "ctrl+o": + return m, m.openModels() case "enter": return m, m.submit() case "ctrl+j": @@ -291,6 +341,10 @@ func (m *Model) handleEvent(ev client.Event) (tea.Model, tea.Cmd) { switch ev.Type { case "session": m.sessionID = ev.SessionID + if ev.AuthToken != "" { + m.authToken = ev.AuthToken + m.tokens.Set(ev.SessionID, ev.AuthToken) + } if ev.Model != "" { m.model = ev.Model } @@ -308,10 +362,12 @@ func (m *Model) handleEvent(ev client.Event) (tea.Model, tea.Cmd) { m.status = "responding" case "tool_call": + arg := argPreview(ev.Data) if i := m.cur(); i >= 0 { - m.msgs[i].steps = append(m.msgs[i].steps, step{name: ev.Name, arg: argPreview(ev.Data)}) + m.msgs[i].steps = append(m.msgs[i].steps, step{name: ev.Name, arg: arg}) } m.lastTool = ev.Name + m.lastArg = arg m.status = "running " + ev.Name case "tool_result": @@ -326,11 +382,13 @@ func (m *Model) handleEvent(ev client.Event) (tea.Model, tea.Cmd) { } } m.lastTool = "" + m.lastArg = "" case "done": m.finalize() m.busy = false m.lastTool = "" + m.lastArg = "" m.status = "ready" m.sessCtxTok = ev.SessionContextTokens m.sessOutTok = ev.SessionOutputTokens @@ -345,6 +403,7 @@ func (m *Model) handleEvent(ev client.Event) (tea.Model, tea.Cmd) { m.finalize() m.busy = false m.lastTool = "" + m.lastArg = "" m.status = "error" case "approval_request": @@ -399,9 +458,16 @@ func (m *Model) submit() tea.Cmd { if m.thinkOn { thinking = "enabled" } + opts := client.PromptOpts{ + Thinking: thinking, + Model: m.pendModel, + SessionID: m.sessionID, + AuthToken: m.authToken, + } + m.pendModel = "" // applied cl := m.cl return func() tea.Msg { - if err := cl.SendPrompt(text, thinking, ""); err != nil { + if err := cl.SendPrompt(text, opts); err != nil { return errMsg{err} } return nil diff --git a/internal/tui/panels.go b/internal/tui/panels.go new file mode 100644 index 0000000..c093523 --- /dev/null +++ b/internal/tui/panels.go @@ -0,0 +1,387 @@ +package tui + +import ( + "fmt" + "strings" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "github.com/BackendStack21/bodek/internal/client" +) + +// panelMode selects the full-area overlay shown in place of the transcript. +type panelMode int + +const ( + panelNone panelMode = iota + panelSessions + panelModels +) + +// ── async results ──────────────────────────────────────────────────────────── + +type sessionsMsg struct { + items []client.Session + err error +} + +type modelsMsg struct { + items []client.ModelInfo + err error +} + +type sessionDetailMsg struct { + sess client.Session + token string + err error +} + +type sessionDeletedMsg struct { + id string + err error +} + +type cancelDoneMsg struct{ err error } + +// ── opening panels ─────────────────────────────────────────────────────────── + +func (m *Model) openSessions() tea.Cmd { + m.panel = panelSessions + m.panelSel = 0 + m.panelMsg = "loading sessions…" + m.relayout() + m.refresh() + cl := m.cl + return func() tea.Msg { + items, err := cl.Sessions() + return sessionsMsg{items: items, err: err} + } +} + +func (m *Model) openModels() tea.Cmd { + m.panel = panelModels + m.panelSel = 0 + m.panelMsg = "loading models…" + m.relayout() + m.refresh() + cl := m.cl + return func() tea.Msg { + items, err := cl.Models() + return modelsMsg{items: items, err: err} + } +} + +func (m *Model) closePanel() { + m.panel = panelNone + m.panelMsg = "" + m.relayout() + m.refresh() +} + +// ── key handling ───────────────────────────────────────────────────────────── + +func (m *Model) handlePanelKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "ctrl+c": + m.quitting = true + return m, tea.Quit + case "esc", "ctrl+r", "ctrl+o", "q": + m.closePanel() + return m, nil + case "up", "ctrl+p", "k": + if m.panelSel > 0 { + m.panelSel-- + m.refresh() + } + return m, nil + case "down", "ctrl+n", "j": + if m.panelSel < m.panelLen()-1 { + m.panelSel++ + m.refresh() + } + return m, nil + case "enter": + return m, m.panelSelect() + case "d", "x": + if m.panel == panelSessions { + return m, m.deleteSelected() + } + } + return m, nil +} + +func (m *Model) panelLen() int { + switch m.panel { + case panelSessions: + return len(m.sessions) + case panelModels: + return len(m.models) + } + return 0 +} + +func (m *Model) panelSelect() tea.Cmd { + switch m.panel { + case panelSessions: + if m.panelSel < len(m.sessions) { + return m.resumeSession(m.sessions[m.panelSel].ID) + } + case panelModels: + if m.panelSel < len(m.models) { + m.pendModel = m.models[m.panelSel].ID + m.model = m.pendModel + m.addNote("model set to " + m.pendModel + " (applies next turn)") + m.closePanel() + } + } + return nil +} + +func (m *Model) resumeSession(id string) tea.Cmd { + m.panelMsg = "loading session…" + m.refresh() + cl := m.cl + token := m.tokens.Get(id) + return func() tea.Msg { + sess, eff, err := cl.SessionDetail(id, token) + return sessionDetailMsg{sess: sess, token: eff, err: err} + } +} + +func (m *Model) deleteSelected() tea.Cmd { + if m.panelSel >= len(m.sessions) { + return nil + } + s := m.sessions[m.panelSel] + cl := m.cl + token := m.tokens.Get(s.ID) + return func() tea.Msg { + // Resolve the token (some legacy sessions mint one on first access), + // then delete. + _, eff, err := cl.SessionDetail(s.ID, token) + if err != nil { + return sessionDeletedMsg{id: s.ID, err: err} + } + return sessionDeletedMsg{id: s.ID, err: cl.DeleteSession(s.ID, eff)} + } +} + +// cancelRun aborts the in-flight prompt via the cancel API. +func (m *Model) cancelRun() tea.Cmd { + if !m.busy || m.sessionID == "" { + return nil + } + m.status = "cancelling" + m.refresh() + cl := m.cl + sid, tok := m.sessionID, m.authToken + return func() tea.Msg { + return cancelDoneMsg{err: cl.Cancel(sid, tok)} + } +} + +// ── async result handling ──────────────────────────────────────────────────── + +func (m *Model) handleSessionsMsg(msg sessionsMsg) { + if msg.err != nil { + m.panelMsg = "error: " + msg.err.Error() + return + } + m.sessions = msg.items + m.panelSel = 0 + if len(m.sessions) == 0 { + m.panelMsg = "no saved sessions yet" + } else { + m.panelMsg = "" + } +} + +func (m *Model) handleModelsMsg(msg modelsMsg) { + if msg.err != nil { + m.panelMsg = "error: " + msg.err.Error() + return + } + m.models = msg.items + m.panelSel = 0 + if len(m.models) == 0 { + m.panelMsg = "no models advertised" + } else { + m.panelMsg = "" + } +} + +func (m *Model) handleSessionDetail(msg sessionDetailMsg) { + if msg.err != nil { + m.panelMsg = "error: " + msg.err.Error() + return + } + // Replay the saved transcript into the local view and resume server-side + // on the next prompt via session_id + auth_token. + m.sessionID = msg.sess.ID + m.authToken = msg.token + m.tokens.Set(msg.sess.ID, msg.token) + if msg.sess.Model != "" { + m.model = msg.sess.Model + } + m.sandbox = msg.sess.Sandbox + m.msgs = m.msgs[:0] + for _, mm := range msg.sess.Messages { + switch mm.Role { + case "user": + m.msgs = append(m.msgs, message{role: roleUser, content: mm.Content}) + case "assistant": + if strings.TrimSpace(mm.Content) == "" { + continue + } + m.msgs = append(m.msgs, message{role: roleAsst, content: mm.Content, rendered: m.render(mm.Content)}) + } + } + m.addNote("resumed session " + shortID(msg.sess.ID)) + m.closePanel() +} + +func (m *Model) handleSessionDeleted(msg sessionDeletedMsg) tea.Cmd { + if msg.err != nil { + m.panelMsg = "delete failed: " + msg.err.Error() + m.refresh() + return nil + } + m.tokens.Delete(msg.id) + if m.panelSel < len(m.sessions) && m.sessions[m.panelSel].ID == msg.id { + m.sessions = append(m.sessions[:m.panelSel], m.sessions[m.panelSel+1:]...) + if m.panelSel >= len(m.sessions) && m.panelSel > 0 { + m.panelSel-- + } + } + if len(m.sessions) == 0 { + m.panelMsg = "no saved sessions yet" + } + m.refresh() + return nil +} + +// ── rendering ──────────────────────────────────────────────────────────────── + +// renderPanel draws the active overlay sized to fill the transcript area. +func (m *Model) renderPanel(w, h int) string { + th := m.th + var title string + var rows []string + + switch m.panel { + case panelSessions: + title = "⟳ resume a session" + rows = m.sessionRows(w - 6) + case panelModels: + title = "✦ choose a model" + rows = m.modelRows(w - 6) + } + + header := th.acTitle.Render(title) + body := header + if m.panelMsg != "" { + body += "\n" + th.acDim.Render(m.panelMsg) + } + if len(rows) > 0 { + // Window the rows around the selection to fit the available height. + visible := h - 4 // border(2) + title(1) + breathing room + if visible < 1 { + visible = 1 + } + body += "\n" + strings.Join(windowRows(rows, m.panelSel, visible), "\n") + } + + box := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(colBrand). + Padding(0, 1). + Width(w - 2). + Height(h - 2). + Render(body) + return box +} + +func (m *Model) sessionRows(w int) []string { + th := m.th + rows := make([]string, 0, len(m.sessions)) + for i, s := range m.sessions { + task := s.Task + if task == "" { + task = "(untitled)" + } + meta := fmt.Sprintf(" %s · %d turns · %s", shortID(s.ID), s.Turns, ago(s.UpdatedAt)) + budget := w - 2 + task = truncate(collapse(task), budget-lipgloss.Width(meta)) + prefix, label := " ", th.acItem.Render(task) + if i == m.panelSel { + prefix, label = th.acSel.Render("› "), th.acSel.Render(task) + } + rows = append(rows, prefix+label+th.acDetail.Render(meta)) + } + return rows +} + +func (m *Model) modelRows(w int) []string { + th := m.th + rows := make([]string, 0, len(m.models)) + for i, md := range m.models { + label := md.ID + detail := "" + if md.Description != "" { + detail = " " + md.Description + } + if md.Current { + detail += " (current)" + } + label = truncate(label, w-2-lipgloss.Width(detail)) + prefix, lab := " ", th.acItem.Render(label) + if i == m.panelSel { + prefix, lab = th.acSel.Render("› "), th.acSel.Render(label) + } + rows = append(rows, prefix+lab+th.acDetail.Render(detail)) + } + return rows +} + +// windowRows returns at most n rows centered on sel. +func windowRows(rows []string, sel, n int) []string { + if len(rows) <= n { + return rows + } + start := sel - n/2 + if start < 0 { + start = 0 + } + if start+n > len(rows) { + start = len(rows) - n + } + return rows[start : start+n] +} + +// shortID trims a session ID for display. +func shortID(id string) string { + if len(id) > 17 { + return id[:17] + "…" + } + return id +} + +// ago renders a coarse relative time. +func ago(t time.Time) string { + if t.IsZero() { + return "—" + } + d := time.Since(t) + switch { + case d < time.Minute: + return "just now" + case d < time.Hour: + return fmt.Sprintf("%dm ago", int(d.Minutes())) + case d < 24*time.Hour: + return fmt.Sprintf("%dh ago", int(d.Hours())) + default: + return fmt.Sprintf("%dd ago", int(d.Hours()/24)) + } +} diff --git a/internal/tui/progress.go b/internal/tui/progress.go new file mode 100644 index 0000000..40a281a --- /dev/null +++ b/internal/tui/progress.go @@ -0,0 +1,128 @@ +package tui + +import ( + "path/filepath" + "strings" +) + +// thinkingPhrases cycle in the status badge while the model reasons before it +// acts, so a pause always feels alive. +var thinkingPhrases = []string{ + "🧠 thinking", + "🔮 reasoning it through", + "🧩 connecting the dots", + "💭 mulling it over", + "✨ planning the approach", + "📐 weighing the options", +} + +// toolProgress returns a playful, context-aware status line for a running tool, +// derived from the tool name and its argument preview. +func toolProgress(name, arg string) string { + n := strings.ToLower(name) + switch { + case strings.Contains(n, "shell"), strings.Contains(n, "bash"), strings.Contains(n, "exec"): + return shellProgress(arg) + case strings.Contains(n, "web_search"): + return "🔎 searching the web" + case strings.Contains(n, "search"), strings.Contains(n, "grep"), strings.Contains(n, "find"): + return "🔎 searching the code" + case strings.Contains(n, "browser"), strings.Contains(n, "http"), strings.Contains(n, "fetch"), strings.Contains(n, "web"): + return "🌐 browsing the web" + case strings.Contains(n, "read"): + return "📖 reading " + base(arg) + case strings.Contains(n, "write"), strings.Contains(n, "patch"), strings.Contains(n, "edit"): + return "📝 writing " + base(arg) + case strings.Contains(n, "list"), strings.Contains(n, "dir"): + return "📂 listing " + base(arg) + case strings.Contains(n, "delegate"), strings.Contains(n, "subagent"), strings.Contains(n, "task"): + return "🤝 delegating to a sub-agent" + case strings.Contains(n, "memory"), strings.Contains(n, "recall"): + return "🧠 recalling from memory" + case strings.Contains(n, "vision"), strings.Contains(n, "image"), strings.Contains(n, "transcribe"): + return "🎬 examining media" + default: + return "🔧 running " + name + } +} + +// shellProgress reads intent from a shell command. +func shellProgress(cmd string) string { + c := strings.ToLower(strings.TrimSpace(cmd)) + switch { + case c == "": + return "❯ running a command" + case has(c, "go test", "npm test", "pytest", "cargo test", "jest"), strings.HasPrefix(c, "test "): + return "🧪 running tests" + case strings.HasPrefix(c, "git "): + return gitProgress(c) + case has(c, "lint", "vet", "gofmt", "prettier", "ruff"): + return "🧹 linting" + case has(c, "build", "compile"), strings.HasPrefix(c, "make"), strings.HasPrefix(c, "cargo b"): + return "🔨 building" + case has(c, "install", "go mod", "npm i", "yarn", "pip ", "apt", "brew"): + return "📦 installing dependencies" + case has(c, "docker", "kubectl", "helm"): + return "🐳 working with containers" + case strings.HasPrefix(c, "curl"), strings.HasPrefix(c, "wget"): + return "🌐 fetching" + case strings.HasPrefix(c, "ls"), strings.HasPrefix(c, "find"), strings.HasPrefix(c, "tree"): + return "📂 looking around" + case has(c, "grep"), prefixAny(c, "cat", "head", "tail", "less", "wc"): + return "🔎 inspecting output" + case prefixAny(c, "rm", "mv", "cp", "mkdir", "touch", "chmod"): + return "🗂 managing files" + default: + return "❯ " + truncate(collapse(cmd), 28) + } +} + +// gitProgress reads intent from a git subcommand. +func gitProgress(c string) string { + switch { + case strings.Contains(c, "commit"): + return "📌 committing" + case strings.Contains(c, "push"): + return "🚀 pushing" + case strings.Contains(c, "clone"): + return "📥 cloning" + case strings.Contains(c, "pull"), strings.Contains(c, "fetch"): + return "🔄 syncing with remote" + case strings.Contains(c, "checkout"), strings.Contains(c, "switch"), strings.Contains(c, "branch"): + return "🌿 switching branches" + case strings.Contains(c, "merge"), strings.Contains(c, "rebase"): + return "🔀 merging" + default: + return "🔀 checking git" + } +} + +// base returns a friendly basename for a path-like argument. +func base(p string) string { + p = strings.TrimSpace(p) + if p == "" { + return "a file" + } + return truncate(filepath.Base(p), 28) +} + +// has reports whether s contains any of the substrings. +func has(s string, subs ...string) bool { + for _, sub := range subs { + if strings.Contains(s, sub) { + return true + } + } + return false +} + +// prefixAny reports whether s starts with any of the given commands (followed +// by a space or end, so "cat" matches "cat x" but not "category"). +func prefixAny(s string, cmds ...string) bool { + for _, cmd := range cmds { + if s == cmd || strings.HasPrefix(s, cmd+" ") { + return true + } + } + return false +} diff --git a/internal/tui/progress_test.go b/internal/tui/progress_test.go new file mode 100644 index 0000000..7ca1f03 --- /dev/null +++ b/internal/tui/progress_test.go @@ -0,0 +1,127 @@ +package tui + +import ( + "strings" + "testing" + "time" +) + +func TestToolProgressBranches(t *testing.T) { + cases := map[string]string{ + "read_file": "reading", + "write_file": "writing", + "list_dir": "listing", + "search_files": "searching the code", + "web_search": "searching the web", + "browser": "browsing", + "delegate_tasks": "sub-agent", + "memory": "recalling", + "vision": "media", + "mystery_tool": "running mystery_tool", + } + for tool, want := range cases { + if got := toolProgress(tool, "x"); !strings.Contains(got, want) { + t.Errorf("toolProgress(%q) = %q, want contains %q", tool, got, want) + } + } +} + +func TestShellProgressBranches(t *testing.T) { + cases := map[string]string{ + "": "running a command", + "go test ./...": "running tests", + "git commit -m x": "committing", + "git push": "pushing", + "git clone url": "cloning", + "git pull": "syncing", + "git checkout main": "switching", + "git merge dev": "merging", + "git status": "checking git", + "golangci-lint run": "linting", + "go build ./...": "building", + "npm install": "installing", + "docker ps": "containers", + "curl https://x": "fetching", + "ls -la": "looking around", + "cat file.txt": "inspecting", + "rm -rf dir": "managing files", + "echo something else": "echo something else", + } + for cmd, want := range cases { + if got := shellProgress(cmd); !strings.Contains(got, want) { + t.Errorf("shellProgress(%q) = %q, want contains %q", cmd, got, want) + } + } +} + +func TestBaseAndHelpers(t *testing.T) { + if base("") != "a file" { + t.Error("base empty") + } + if base("a/b/c.go") != "c.go" { + t.Errorf("base = %q", base("a/b/c.go")) + } + if !has("hello world", "nope", "world") { + t.Error("has should match") + } + if has("abc", "x", "y") { + t.Error("has should not match") + } + if !prefixAny("cat x", "cat", "head") { + t.Error("prefixAny should match 'cat x'") + } + if prefixAny("category", "cat") { + t.Error("prefixAny should not match 'category'") + } +} + +func TestAgo(t *testing.T) { + now := time.Now() + cases := []struct { + in time.Time + want string + }{ + {time.Time{}, "—"}, + {now.Add(-10 * time.Second), "just now"}, + {now.Add(-5 * time.Minute), "m ago"}, + {now.Add(-3 * time.Hour), "h ago"}, + {now.Add(-48 * time.Hour), "d ago"}, + } + for _, c := range cases { + if got := ago(c.in); !strings.Contains(got, strings.TrimPrefix(c.want, "")) { + t.Errorf("ago(%v) = %q, want contains %q", c.in, got, c.want) + } + } +} + +func TestShortID(t *testing.T) { + if shortID("short") != "short" { + t.Error("shortID short") + } + long := "20260618-bf2127d911081dd521e3dc" + if got := shortID(long); !strings.HasSuffix(got, "…") { + t.Errorf("shortID long = %q", got) + } +} + +func TestWindowRows(t *testing.T) { + rows := []string{"a", "b", "c", "d", "e"} + if got := windowRows(rows, 0, 10); len(got) != 5 { + t.Errorf("windowRows fit = %d", len(got)) + } + if got := windowRows(rows, 4, 3); len(got) != 3 || got[2] != "e" { + t.Errorf("windowRows end = %v", got) + } + if got := windowRows(rows, 2, 3); got[1] != "c" { + t.Errorf("windowRows mid = %v", got) + } +} + +func TestFormatDuration(t *testing.T) { + if got := formatDuration(3500 * time.Millisecond); got != "3.5s" { + t.Errorf("formatDuration short = %q", got) + } + if got := formatDuration(90 * time.Second); got != "1m30s" { + t.Errorf("formatDuration long = %q", got) + } +} diff --git a/internal/tui/view.go b/internal/tui/view.go index 2f6dcdb..c718d55 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -14,9 +14,13 @@ func (m *Model) View() string { if !m.ready { return "\n starting bodek…" } + body := m.vp.View() + if m.panel != panelNone { + body = m.renderPanel(m.width, m.vp.Height) + } parts := []string{ m.header(), - m.vp.View(), + body, m.inputArea(), m.footer(), } @@ -29,10 +33,6 @@ func (m *Model) header() string { th := m.th logo := th.logo.Render(gradient("⬡ bodek", gradFrom, gradTo)) - sandbox := "off" - if m.sandbox { - sandbox = "on" - } think := "off" if m.thinkOn { think = "on" @@ -41,11 +41,18 @@ func (m *Model) header() string { if modelName == "" { modelName = "default" } - meta := th.headerMeta.Render(" · sandbox "+sandbox+" · think ") + - th.headerKey.Render(think) + // Sandbox status, prominently colored: green shield when isolated, amber + // warning when the agent has host access. + var sandbox string + if m.sandbox { + sandbox = lipgloss.NewStyle().Foreground(colGreen).Render("🛡 sandboxed") + } else { + sandbox = lipgloss.NewStyle().Foreground(colYellow).Render("⚠ host access") + } + meta := th.headerMeta.Render(" · think ") + th.headerKey.Render(think) model := th.headerKey.Render(modelName) - left := logo + " " + model + meta + left := logo + " " + model + th.headerMeta.Render(" · ") + sandbox + meta status := m.statusBadge() tokens := th.headerMeta.Render(fmt.Sprintf("∑ ⌂ %s · ⎇ %s", @@ -70,12 +77,6 @@ func (m *Model) rule() string { return m.gradRule } -// engagingVerbs cycle while the model is reasoning, so the UI feels alive. -var engagingVerbs = []string{ - "thinking", "reasoning it through", "connecting the dots", - "consulting the model", "weighing the options", "planning the approach", -} - func (m *Model) statusBadge() string { th := m.th switch { @@ -84,23 +85,22 @@ func (m *Model) statusBadge() string { case m.approval != nil: return th.statusBusy.Render("⚠ approval required") case m.busy: - label := m.status + var label string switch { case m.lastTool != "": - label = th.toolIcon.Render(toolGlyph(m.lastTool)) + " " + - th.statusBusy.Render(m.lastTool) - case label == "thinking", label == "": - // Cycle engaging verbs roughly every ~1.8s of the run. - idx := int(time.Since(m.runStart)/(1800*time.Millisecond)) % len(engagingVerbs) - label = th.statusBusy.Render(engagingVerbs[idx]) - default: - label = th.statusBusy.Render(label) + // Context-aware message derived from the running tool + its args. + label = toolProgress(m.lastTool, m.lastArg) + case m.status == "responding": + label = "💬 composing the reply" + default: // thinking / pre-tool: cycle phrases so a pause feels alive. + idx := int(time.Since(m.runStart)/(1500*time.Millisecond)) % len(thinkingPhrases) + label = thinkingPhrases[idx] } el := "" if e := m.elapsed(); e != "" { el = th.headerMeta.Render(" · " + e) } - return th.spinner.Render(m.sp.View()) + " " + label + el + return th.spinner.Render(m.sp.View()) + " " + th.statusBusy.Render(label) + el default: return th.statusReady.Render("● " + m.status) } @@ -298,20 +298,31 @@ func (m *Model) approvalPanel() string { func (m *Model) footer() string { th := m.th + sep := th.footerSep.Render(" · ") if m.approval != nil { return th.footer.Render(" answer the approval prompt to continue") } if m.disconn { return th.footer.Render(" connection closed · press ^C to quit") } - sep := th.footerSep.Render(" · ") - keys := []string{ - th.footerKey.Render("⏎") + th.footer.Render(" send"), - th.footerKey.Render("^J") + th.footer.Render(" newline"), - th.footerKey.Render("^T") + th.footer.Render(" thinking"), - th.footerKey.Render("^L") + th.footer.Render(" clear"), - th.footerKey.Render("^C") + th.footer.Render(" quit"), + if m.panel == panelSessions { + return m.panelFooter("↑↓ select", "⏎ resume", "d delete", "esc close") + } + if m.panel == panelModels { + return m.panelFooter("↑↓ select", "⏎ use", "esc close") + } + var keys []string + if m.busy { + keys = append(keys, th.footerKey.Render("esc")+th.footer.Render(" cancel")) } + keys = append(keys, + th.footerKey.Render("⏎")+th.footer.Render(" send"), + th.footerKey.Render("@")+th.footer.Render(" attach"), + th.footerKey.Render("^R")+th.footer.Render(" sessions"), + th.footerKey.Render("^O")+th.footer.Render(" model"), + th.footerKey.Render("^T")+th.footer.Render(" thinking"), + th.footerKey.Render("^C")+th.footer.Render(" quit"), + ) left := " " + strings.Join(keys, sep) var segs []string @@ -332,6 +343,16 @@ func (m *Model) footer() string { return left + strings.Repeat(" ", gap) + right } +// panelFooter renders a simple hint line for an open panel. +func (m *Model) panelFooter(hints ...string) string { + th := m.th + parts := make([]string, len(hints)) + for i, h := range hints { + parts[i] = th.footer.Render(h) + } + return " " + strings.Join(parts, th.footerSep.Render(" · ")) +} + // ── small helpers ────────────────────────────────────────────────────────── func orDash(s string) string { From fee8bbe31115796ea8a0481cc1fa53e8033aa1a5 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 18 Jun 2026 08:57:20 +0000 Subject: [PATCH 4/5] fix(security): sanitize untrusted terminal output; fix CI lint action MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verification-protocol finding (Axis 2.8 Adversarial Surface / 2.3 Security): agent-influenced output — streamed tokens, tool args/results, reasoning, engine notices, and resumed transcripts — was written to the terminal without stripping ANSI/control escape sequences, a terminal-injection risk (cursor/screen manipulation, OSC 52 clipboard exfiltration). - add sanitize()/isControl(): strip C0 control bytes + DEL (incl. ESC), keeping newlines/tabs; applied to all untrusted display paths (collapse, streamed token + thinking ingestion, addNote, resumed transcripts) - tests for the sanitizer and end-to-end defanging CI: golangci-lint v2 requires golangci-lint-action v7+ (v6 rejects v2.x) — bump the action so the Lint job runs. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_012rWqu7pktbd2ejfNw3a7Vf --- .github/workflows/ci.yml | 2 +- internal/tui/gaps_test.go | 37 +++++++++++++++++++++++++++++++++++++ internal/tui/model.go | 38 ++++++++++++++++++++++++++++++++++---- internal/tui/panels.go | 9 ++++++--- 4 files changed, 78 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9c8a20e..33c41cd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -52,6 +52,6 @@ jobs: cache: true - name: golangci-lint - uses: golangci/golangci-lint-action@v6 + uses: golangci/golangci-lint-action@v7 with: version: v2.5.0 diff --git a/internal/tui/gaps_test.go b/internal/tui/gaps_test.go index b4ccb65..9b05d75 100644 --- a/internal/tui/gaps_test.go +++ b/internal/tui/gaps_test.go @@ -1,6 +1,7 @@ package tui import ( + "strings" "testing" "github.com/BackendStack21/bodek/internal/client" @@ -45,3 +46,39 @@ func TestArgPreviewURLKey(t *testing.T) { t.Errorf("argPreview url = %q", got) } } + +func TestSanitizeStripsControlSequences(t *testing.T) { + // ESC-based screen clear + OSC 52 clipboard write must be defanged. + evil := "ok\x1b[2Jclear\x1b]52;c;ZXZpbA==\x07 \x7f\x00 plain\ttab\nnl" + got := sanitize(evil) + for _, bad := range []rune{'\x1b', '\x07', '\x00', '\x7f'} { + if strings.ContainsRune(got, bad) { + t.Errorf("sanitize left control byte %q in %q", bad, got) + } + } + if !strings.Contains(got, "plain") || !strings.Contains(got, "\t") || !strings.Contains(got, "\n") { + t.Errorf("sanitize dropped legitimate text/whitespace: %q", got) + } + // Fast path: clean input is returned unchanged. + if sanitize("hello world") != "hello world" { + t.Error("sanitize altered clean input") + } +} + +func TestUntrustedOutputDefanged(t *testing.T) { + m := wired(t) + m.msgs = append(m.msgs, message{role: roleAsst, streaming: true}) + m.curIdx = 0 + m.handleEvent(client.Event{Type: "tool_call", Name: "shell", Data: "{\"command\":\"x\x1b[2J\"}"}) + m.handleEvent(client.Event{Type: "tool_result", Name: "shell", Data: "out\x1b]0;pwn"}) + m.handleEvent(client.Event{Type: "token", Content: "hi\x1b[31m"}) + + if strings.ContainsRune(m.msgs[0].content, '\x1b') { + t.Error("streamed token escape not sanitized") + } + for _, s := range m.msgs[0].steps { + if strings.ContainsRune(s.arg, '\x1b') || strings.ContainsRune(s.result, '\x1b') { + t.Error("tool step escape not sanitized") + } + } +} diff --git a/internal/tui/model.go b/internal/tui/model.go index f6154af..99a01a7 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -351,12 +351,12 @@ func (m *Model) handleEvent(ev client.Event) (tea.Model, tea.Cmd) { m.sandbox = ev.Sandbox case "thinking": - m.thinking.WriteString(ev.Content) + m.thinking.WriteString(sanitize(ev.Content)) m.status = "thinking" case "token": if i := m.cur(); i >= 0 { - m.msgs[i].content += ev.Content + m.msgs[i].content += sanitize(ev.Content) m.msgs[i].streaming = true } m.status = "responding" @@ -509,7 +509,7 @@ func (m *Model) finalize() { } func (m *Model) addNote(s string) { - m.notices = append(m.notices, s) + m.notices = append(m.notices, sanitize(s)) if len(m.notices) > 6 { m.notices = m.notices[len(m.notices)-6:] } @@ -695,7 +695,37 @@ func linePreview(data string) string { } func collapse(s string) string { - return strings.Join(strings.Fields(s), " ") + return strings.Join(strings.Fields(sanitize(s)), " ") +} + +// sanitize strips terminal control sequences from untrusted content before it +// is rendered. Agent output — streamed tokens, tool results, file contents, +// resumed transcripts — is attacker-influenced; raw C0 control bytes (notably +// ESC, 0x1b) could drive ANSI/OSC escapes that move the cursor, clear the +// screen, or exfiltrate via OSC 52. We keep newlines and tabs and drop every +// other control byte (and DEL), which defangs escape sequences by removing +// their introducer while leaving readable text intact. +func sanitize(s string) string { + if !strings.ContainsFunc(s, isControl) { + return s + } + var b strings.Builder + b.Grow(len(s)) + for _, r := range s { + if !isControl(r) { + b.WriteRune(r) + } + } + return b.String() +} + +// isControl reports whether r is a control character we strip from untrusted +// text (C0 controls and DEL, except newline and tab). +func isControl(r rune) bool { + if r == '\n' || r == '\t' { + return false + } + return r < 0x20 || r == 0x7f } // formatDuration renders a short, friendly elapsed time. diff --git a/internal/tui/panels.go b/internal/tui/panels.go index c093523..f894c16 100644 --- a/internal/tui/panels.go +++ b/internal/tui/panels.go @@ -228,14 +228,17 @@ func (m *Model) handleSessionDetail(msg sessionDetailMsg) { m.sandbox = msg.sess.Sandbox m.msgs = m.msgs[:0] for _, mm := range msg.sess.Messages { + // Persisted transcripts are attacker-influenced (agent output, and the + // session file itself); strip terminal control sequences before display. + content := sanitize(mm.Content) switch mm.Role { case "user": - m.msgs = append(m.msgs, message{role: roleUser, content: mm.Content}) + m.msgs = append(m.msgs, message{role: roleUser, content: content}) case "assistant": - if strings.TrimSpace(mm.Content) == "" { + if strings.TrimSpace(content) == "" { continue } - m.msgs = append(m.msgs, message{role: roleAsst, content: mm.Content, rendered: m.render(mm.Content)}) + m.msgs = append(m.msgs, message{role: roleAsst, content: content, rendered: m.render(content)}) } } m.addNote("resumed session " + shortID(msg.sess.ID)) From ee682086058923164ba77e5df6a8fccbbccab3d7 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 18 Jun 2026 09:23:06 +0000 Subject: [PATCH 5/5] feat(tui): /command palette; scope @ to file attachments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add standard slash-command support and a completion palette that mirrors the @ popup: - type "/" for a command palette (↑/↓ select, ⇥ complete, ⏎ run, esc): /help, /clear, /sessions, /model [name], /thinking [on|off], /cancel, /quit - a fully-typed "/cmd args" + ⏎ runs directly; commands work mid-turn (e.g. /cancel) and never get sent to the agent - /help renders a command + keybinding card Scope @ to attachments only: the completion now suggests files (sessions are reached via /sessions or ^R), with a relabeled popup and a command glyph. README, welcome tips, and key-binding docs updated. Fully covered by tests (commands.go 100%); suite + lint green. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_012rWqu7pktbd2ejfNw3a7Vf --- README.md | 42 ++++--- internal/tui/banner.go | 3 +- internal/tui/commands.go | 156 +++++++++++++++++++++++++ internal/tui/commands_test.go | 199 ++++++++++++++++++++++++++++++++ internal/tui/final_gaps_test.go | 2 +- internal/tui/icons.go | 2 + internal/tui/model.go | 76 +++++++++--- internal/tui/view.go | 14 ++- 8 files changed, 461 insertions(+), 33 deletions(-) create mode 100644 internal/tui/commands.go create mode 100644 internal/tui/commands_test.go diff --git a/README.md b/README.md index aa1a0fa..b4712f4 100644 --- a/README.md +++ b/README.md @@ -81,8 +81,9 @@ by `odek serve` from its usual chain — `~/.odek/config.json` → `./odek.json` | Key | Action | |-----|--------| -| `⏎` | Send the prompt | -| `@` | Open file/session reference completion (see below) | +| `⏎` | Send the prompt (or run a `/command`) | +| `/` | Open the command palette (see below) | +| `@` | Attach a file (see below) | | `^R` | Browse & resume saved sessions | | `^O` | Switch the model | | `^T` | Toggle extended thinking for the next turn | @@ -92,24 +93,35 @@ by `odek serve` from its usual chain — `~/.odek/config.json` → `./odek.json` | `PgUp` / `PgDn` / wheel | Scroll the transcript | | `^C` | Quit | -### File attachments / `@` references +### Commands (`/`) -Type `@` in the input to attach context. bodek queries odek's resource index -live and shows a completion popup; `↑`/`↓` to choose, `⏎` or `⇥` to insert, -`esc` to dismiss. +Type `/` at the start of the input for a command palette. `↑`/`↓` to choose, +`⇥` to complete, `⏎` to run, `esc` to dismiss. You can also just type the full +command and press `⏎`. -| Reference | Resolves to | -|-----------|-------------| -| `@path/to/file` | The file's contents, inlined into your prompt | -| `@sess:` | A saved session transcript | +| Command | Action | +|---------|--------| +| `/help` | List commands and key bindings | +| `/clear` | Clear the conversation | +| `/sessions` | Browse & resume saved sessions | +| `/model [name]` | Switch model (opens a picker with no argument) | +| `/thinking [on\|off]` | Toggle extended thinking | +| `/cancel` | Cancel the running turn | +| `/quit` | Exit bodek | + +### File attachments (`@`) + +Type `@` to attach a file. bodek searches the working tree and shows a +completion popup; `↑`/`↓` to choose, `⏎` or `⇥` to insert, `esc` to dismiss. ``` -> summarize @internal/client/client.go and compare it with @sess:20260618-ab12 +> summarize @internal/client/client.go and explain the protocol ``` -odek resolves and inlines the referenced content **server-side** (wrapped in -its untrusted-content boundary), so attachments go through the same security -model as any other external input — bodek doesn't special-case them. +odek resolves and inlines the file content **server-side** (wrapped in its +untrusted-content boundary), so attachments go through the same security model +as any other external input — bodek doesn't special-case them. (Saved sessions +are resumed via `/sessions` or `^R`, not `@`.) When the agent requests approval for a dangerous operation, answer inline: @@ -130,7 +142,7 @@ When the agent requests approval for a dangerous operation, answer inline: panel; your answer is sent straight back over the socket. - **Live reasoning** — the model's pre-tool thinking streams in dimmed text, with a running elapsed timer and cycling status while it works. -- **`@` autocomplete** — a live, navigable popup of files and sessions. +- **Command palette (`/`)** and **file attachments (`@`)** — live, navigable popups. - **Context-aware progress** — while the agent works, the status badge shows what it's actually doing (`🧪 running tests`, `📖 reading client.go`, `🚀 pushing`) with a live elapsed timer. diff --git a/internal/tui/banner.go b/internal/tui/banner.go index 4e11f22..5b75742 100644 --- a/internal/tui/banner.go +++ b/internal/tui/banner.go @@ -35,7 +35,8 @@ func welcome(th theme, width int) string { tips := [][2]string{ {"type a task", "and press enter to run the agent"}, - {"@ to attach", "reference files & sessions, e.g. @main.go"}, + {"/ commands", "type / for commands, e.g. /help /sessions /model"}, + {"@ to attach", "attach files, e.g. @main.go"}, {"⏎ send", "· ^J newline · ^T toggle thinking"}, {"^L clear", "· PgUp/PgDn scroll · ^C quit"}, {"approvals", "answer with [a]pprove [d]eny [t]rust"}, diff --git a/internal/tui/commands.go b/internal/tui/commands.go new file mode 100644 index 0000000..4244209 --- /dev/null +++ b/internal/tui/commands.go @@ -0,0 +1,156 @@ +package tui + +import ( + "fmt" + "strings" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/BackendStack21/bodek/internal/client" +) + +// command is a slash command typed in the input as "/name [args]". +type command struct { + name string + desc string + run func(m *Model, args string) tea.Cmd +} + +// slashCommands is the registry. Keeping it a function keeps the closures +// simple and avoids package-init ordering concerns. +func slashCommands() []command { + return []command{ + {"help", "show available commands", func(m *Model, _ string) tea.Cmd { + m.showHelp() + return nil + }}, + {"clear", "clear the conversation", func(m *Model, _ string) tea.Cmd { + m.msgs = nil + m.curIdx = -1 + m.refresh() + return nil + }}, + {"sessions", "browse & resume saved sessions", func(m *Model, _ string) tea.Cmd { + return m.openSessions() + }}, + {"model", "switch model — /model [name]", func(m *Model, args string) tea.Cmd { + if args != "" { + m.pendModel = args + m.model = args + m.addNote("model set to " + args + " (applies next turn)") + m.refresh() + return nil + } + return m.openModels() + }}, + {"thinking", "extended thinking — /thinking [on|off]", func(m *Model, args string) tea.Cmd { + switch strings.ToLower(args) { + case "on", "enabled", "true": + m.thinkOn = true + case "off", "disabled", "false": + m.thinkOn = false + default: + m.thinkOn = !m.thinkOn + } + state := "off" + if m.thinkOn { + state = "on" + } + m.addNote("thinking " + state) + m.refresh() + return nil + }}, + {"cancel", "cancel the running turn", func(m *Model, _ string) tea.Cmd { + return m.cancelRun() + }}, + {"quit", "exit bodek", func(m *Model, _ string) tea.Cmd { + m.quitting = true + return tea.Quit + }}, + } +} + +// commandPrefix reports the command token when the input is a line-initial +// slash command still being typed (a leading "/" with no whitespace yet). +func commandPrefix(s string) (string, bool) { + if !strings.HasPrefix(s, "/") { + return "", false + } + body := s[1:] + if strings.ContainsAny(body, " \t\n") { + return "", false + } + return body, true +} + +// runCommandLine parses and dispatches a full "/name args" line. +func (m *Model) runCommandLine(text string) tea.Cmd { + body := strings.TrimPrefix(text, "/") + name, args := body, "" + if i := strings.IndexAny(body, " \t"); i >= 0 { + name, args = body[:i], strings.TrimSpace(body[i+1:]) + } + return m.runCommand(name, args) +} + +// runCommand finds and executes a command by name, resetting the input. +func (m *Model) runCommand(name, args string) tea.Cmd { + m.ta.Reset() + m.closeAC() + for _, c := range slashCommands() { + if c.name == name { + return c.run(m, args) + } + } + m.addNote("unknown command: /" + name + " — try /help") + m.refresh() + return nil +} + +// runSelectedCommand executes the command highlighted in the popup. +func (m *Model) runSelectedCommand() tea.Cmd { + if len(m.ac.items) == 0 { + m.closeAC() + return nil + } + name := strings.TrimPrefix(m.ac.items[m.ac.sel].ID, "/") + return m.runCommand(name, "") +} + +// openCmdAC populates the popup with commands matching the typed prefix. +func (m *Model) openCmdAC(query string) { + var items []client.Resource + for _, c := range slashCommands() { + if strings.HasPrefix(c.name, query) { + items = append(items, client.Resource{ + ID: "/" + c.name, Type: "command", Label: "/" + c.name, Detail: c.desc, + }) + } + } + m.ac.open = true + m.ac.loading = false + m.ac.mode = acCmd + m.ac.query = query + m.ac.items = items + m.ac.seq++ // invalidate any in-flight @-search result + if m.ac.sel >= len(items) { + m.ac.sel = 0 + } + m.relayout() + m.refresh() +} + +// showHelp appends a rendered help card listing commands and key bindings. +func (m *Model) showHelp() { + var b strings.Builder + b.WriteString("### Commands\n\n") + for _, c := range slashCommands() { + b.WriteString(fmt.Sprintf("- `/%s` — %s\n", c.name, c.desc)) + } + b.WriteString("\n### Keys\n\n") + b.WriteString("`@` attach files/sessions · `^R` sessions · `^O` model · " + + "`^T` thinking · `^J` newline · `Esc` cancel · `^L` clear · `^C` quit\n") + content := b.String() + m.msgs = append(m.msgs, message{role: roleAsst, content: content, rendered: m.render(content)}) + m.refresh() +} diff --git a/internal/tui/commands_test.go b/internal/tui/commands_test.go new file mode 100644 index 0000000..a3bf1cf --- /dev/null +++ b/internal/tui/commands_test.go @@ -0,0 +1,199 @@ +package tui + +import ( + "strings" + "testing" +) + +func TestCommandPrefix(t *testing.T) { + cases := []struct { + in string + want string + ok bool + }{ + {"/he", "he", true}, + {"/", "", true}, + {"/model gpt", "", false}, // has a space → no longer completing the name + {"hello", "", false}, + {"a/b", "", false}, + } + for _, c := range cases { + got, ok := commandPrefix(c.in) + if ok != c.ok || got != c.want { + t.Errorf("commandPrefix(%q) = (%q,%v), want (%q,%v)", c.in, got, ok, c.want, c.ok) + } + } +} + +func TestSlashCommandsViaSubmit(t *testing.T) { + m := wired(t) + + // /clear + m.msgs = append(m.msgs, message{role: roleUser, content: "x"}) + m.ta.SetValue("/clear") + exec(m.submit()) + if len(m.msgs) != 0 { + t.Errorf("/clear left %d messages", len(m.msgs)) + } + + // /help appends a rendered help card; input is reset. + m.ta.SetValue("/help") + exec(m.submit()) + if len(m.msgs) == 0 || !strings.Contains(m.msgs[len(m.msgs)-1].content, "Commands") { + t.Error("/help did not append a help card") + } + if m.ta.Value() != "" { + t.Errorf("input not reset after command: %q", m.ta.Value()) + } + + // /thinking on / off / toggle + m.ta.SetValue("/thinking on") + exec(m.submit()) + if !m.thinkOn { + t.Error("/thinking on failed") + } + m.ta.SetValue("/thinking off") + exec(m.submit()) + if m.thinkOn { + t.Error("/thinking off failed") + } + m.ta.SetValue("/thinking") + exec(m.submit()) + if !m.thinkOn { + t.Error("/thinking toggle failed") + } + + // /model with an argument sets the pending model. + m.ta.SetValue("/model gpt-4o") + exec(m.submit()) + if m.pendModel != "gpt-4o" || m.model != "gpt-4o" { + t.Errorf("/model arg = %q/%q", m.pendModel, m.model) + } + + // Unknown command surfaces a notice rather than being sent to the agent. + m.ta.SetValue("/nope") + exec(m.submit()) + found := false + for _, n := range m.notices { + if strings.Contains(n, "unknown command") { + found = true + } + } + if !found { + t.Error("unknown command produced no notice") + } + if m.busy { + t.Error("a slash command must not start an agent turn") + } +} + +func TestSlashCommandOpensPanels(t *testing.T) { + m := wired(t) + m.ta.SetValue("/sessions") + exec(m.submit()) + if m.panel != panelSessions { + t.Errorf("/sessions did not open the panel: %d", m.panel) + } + m.closePanel() + + m.ta.SetValue("/model") + exec(m.submit()) + if m.panel != panelModels { + t.Errorf("/model (no arg) did not open the model picker: %d", m.panel) + } +} + +func TestCommandCompletion(t *testing.T) { + m := wired(t) + m.ta.SetValue("/se") + m.syncAC() + if !m.ac.open || m.ac.mode != acCmd { + t.Fatalf("command popup not open in cmd mode (open=%v mode=%d)", m.ac.open, m.ac.mode) + } + if len(m.ac.items) == 0 || m.ac.items[0].ID != "/sessions" { + t.Fatalf("completion items = %+v", m.ac.items) + } + // Tab completes the command name with a trailing space. + m.acceptCompletion() + if m.ta.Value() != "/sessions " { + t.Errorf("accepted value = %q", m.ta.Value()) + } + if m.ac.open { + t.Error("popup should close after accept") + } +} + +func TestCommandPopupEnterExecutes(t *testing.T) { + m := wired(t) + m.msgs = append(m.msgs, message{role: roleUser, content: "x"}) + m.ta.SetValue("/cl") + m.syncAC() // opens popup with /clear highlighted + if m.ac.mode != acCmd || len(m.ac.items) == 0 { + t.Fatalf("cmd popup not ready: %+v", m.ac) + } + m.Update(key("enter")) // executes the highlighted command directly + if len(m.msgs) != 0 { + t.Errorf("/clear via popup enter left %d messages", len(m.msgs)) + } + if m.ac.open { + t.Error("popup should close after executing") + } +} + +func TestSlashCancelAndQuit(t *testing.T) { + m := wired(t) + m.busy = true + m.sessionID = "s1" + m.authToken = "a1" + m.ta.SetValue("/cancel") + if cmd := m.submit(); cmd == nil { + t.Error("/cancel should return a cancel command while busy") + } else { + exec(cmd) + } + + m.ta.SetValue("/quit") + _ = m.submit() + if !m.quitting { + t.Error("/quit should set quitting") + } +} + +func TestRunSelectedCommandEmpty(t *testing.T) { + m := wired(t) + m.ac.open = true + m.ac.mode = acCmd + m.ac.items = nil + if m.runSelectedCommand() != nil { + t.Error("runSelectedCommand with no items should be nil") + } + if m.ac.open { + t.Error("popup should close") + } +} + +func TestOpenCmdACResetsSelection(t *testing.T) { + m := wired(t) + m.ac.sel = 5 + m.openCmdAC("model") // matches a single command + if m.ac.sel != 0 { + t.Errorf("selection not reset: %d", m.ac.sel) + } + if len(m.ac.items) != 1 || m.ac.items[0].ID != "/model" { + t.Errorf("openCmdAC items = %+v", m.ac.items) + } +} + +func TestCommandModeDropsStaleRefResult(t *testing.T) { + m := wired(t) + // Simulate an in-flight @-search, then switch to command mode. + m.ac.mode = acRef + m.ac.seq = 5 + m.ta.SetValue("/he") + m.syncAC() // switches to cmd mode and bumps seq + // A late ref result for the old seq must be ignored. + m.Update(acResultMsg{seq: 5, items: nil}) + if m.ac.mode != acCmd { + t.Error("stale ref result clobbered command-mode popup") + } +} diff --git a/internal/tui/final_gaps_test.go b/internal/tui/final_gaps_test.go index 89de0c6..cda16e0 100644 --- a/internal/tui/final_gaps_test.go +++ b/internal/tui/final_gaps_test.go @@ -59,7 +59,7 @@ func TestAcPopupLoadingAndEmpty(t *testing.T) { m.ac.loading = false m.ac.items = nil m.ac.query = "zzz" - if !strings.Contains(plain(m.acPopup()), "no matches") { + if !strings.Contains(plain(m.acPopup()), "no matching") { t.Error("empty popup missing") } } diff --git a/internal/tui/icons.go b/internal/tui/icons.go index 05dba24..bd9b592 100644 --- a/internal/tui/icons.go +++ b/internal/tui/icons.go @@ -34,6 +34,8 @@ func toolGlyph(name string) string { // resourceGlyph returns a glyph for an @-reference result type. func resourceGlyph(typ string) string { switch typ { + case "command": + return "/" case "session": return "⟳" case "skill": diff --git a/internal/tui/model.go b/internal/tui/model.go index 99a01a7..8c46056 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -103,10 +103,19 @@ type Model struct { gradRuleW int } -// autocomplete holds the @-reference completion popup state. +// acMode selects what the completion popup is completing. +type acMode int + +const ( + acRef acMode = iota // @-references (files/sessions), searched server-side + acCmd // slash commands, filtered locally +) + +// autocomplete holds the completion popup state (shared by @ and / modes). type autocomplete struct { open bool loading bool + mode acMode query string items []client.Resource sel int @@ -197,8 +206,8 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case acResultMsg: - if msg.seq != m.ac.seq { - return m, nil // stale response + if msg.seq != m.ac.seq || m.ac.mode != acRef { + return m, nil // stale response, or popup switched to command mode } m.ac.loading = false m.ac.items = msg.items @@ -287,7 +296,14 @@ func (m *Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.refresh() } return m, nil - case "tab", "enter": + case "tab": + m.acceptCompletion() + return m, nil + case "enter": + // A fully-typed command executes; a reference is inserted. + if m.ac.mode == acCmd { + return m, m.runSelectedCommand() + } m.acceptCompletion() return m, nil case "esc": @@ -436,13 +452,17 @@ func (m *Model) handleEvent(ev client.Event) (tea.Model, tea.Cmd) { // ── actions ────────────────────────────────────────────────────────────── func (m *Model) submit() tea.Cmd { - if m.busy || m.disconn { - return nil - } text := strings.TrimSpace(m.ta.Value()) if text == "" { return nil } + // Slash commands run locally and are allowed even mid-turn (e.g. /cancel). + if strings.HasPrefix(text, "/") { + return m.runCommandLine(text) + } + if m.busy || m.disconn { + return nil + } m.msgs = append(m.msgs, message{role: roleUser, content: text}) m.msgs = append(m.msgs, message{role: roleAsst, streaming: true}) m.curIdx = len(m.msgs) - 1 @@ -599,20 +619,33 @@ func refStart(s string) (int, bool) { return loc[4] - 1, true // group 2 start, minus the '@' } -// syncAC re-evaluates the input for an @-reference and kicks off a search. +// syncAC re-evaluates the input and drives the completion popup — slash +// commands (filtered locally) or @-references (searched server-side). func (m *Model) syncAC() tea.Cmd { - q, ok := activeRef(m.ta.Value()) + val := m.ta.Value() + + // Line-initial slash command completion. + if name, ok := commandPrefix(val); ok { + if m.ac.open && m.ac.mode == acCmd && m.ac.query == name { + return nil + } + m.openCmdAC(name) + return nil + } + + q, ok := activeRef(val) if !ok { if m.ac.open { m.closeAC() } return nil } - if m.ac.open && q == m.ac.query { + if m.ac.open && m.ac.mode == acRef && q == m.ac.query { return nil // nothing changed } m.ac.open = true m.ac.loading = true + m.ac.mode = acRef m.ac.query = q m.ac.sel = 0 m.ac.seq++ @@ -622,21 +655,38 @@ func (m *Model) syncAC() tea.Cmd { cl := m.cl return func() tea.Msg { - items, err := cl.Resources(q, 6) + // @ is for file attachments only; sessions are reached via /sessions + // (or ^R). Over-fetch, then keep just files. + items, err := cl.Resources(q, 12) if err != nil { return acResultMsg{seq: seq, items: nil} } - return acResultMsg{seq: seq, items: items} + files := make([]client.Resource, 0, len(items)) + for _, it := range items { + if it.Type == "file" { + files = append(files, it) + } + } + if len(files) > 6 { + files = files[:6] + } + return acResultMsg{seq: seq, items: files} } } -// acceptCompletion inserts the selected resource reference into the input. +// acceptCompletion inserts the highlighted item into the input. func (m *Model) acceptCompletion() { if len(m.ac.items) == 0 { m.closeAC() return } item := m.ac.items[m.ac.sel] + if m.ac.mode == acCmd { + m.ta.SetValue(item.ID + " ") + m.ta.CursorEnd() + m.closeAC() + return + } val := m.ta.Value() if idx, ok := refStart(val); ok { m.ta.SetValue(val[:idx] + item.ID + " ") diff --git a/internal/tui/view.go b/internal/tui/view.go index c718d55..c46b0f6 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -235,8 +235,12 @@ func (m *Model) acPopup() string { innerW = 12 } - title := th.acTitle.Render("@ reference") - if hint := " ↑↓ select · ⇥ insert · esc cancel"; 11+lipgloss.Width(hint) <= innerW { + label, hint := "@ attach file", " ↑↓ select · ⇥ insert · esc cancel" + if m.ac.mode == acCmd { + label, hint = "commands", " ↑↓ select · ⇥ complete · ⏎ run · esc cancel" + } + title := th.acTitle.Render(label) + if lipgloss.Width(label)+lipgloss.Width(hint) <= innerW { title += th.acDim.Render(hint) } @@ -245,7 +249,11 @@ func (m *Model) acPopup() string { case m.ac.loading && len(m.ac.items) == 0: rows = append(rows, th.acDim.Render(m.sp.View()+" searching…")) case len(m.ac.items) == 0: - rows = append(rows, th.acDim.Render("no matches for @"+m.ac.query)) + noun := "files" + if m.ac.mode == acCmd { + noun = "commands" + } + rows = append(rows, th.acDim.Render("no matching "+noun)) default: for i, it := range m.ac.items { // Truncate in plain text first so styled rows never wrap.