From 5a6b68d38fd957727da9b3a1e34d247255e2bbf2 Mon Sep 17 00:00:00 2001 From: Steven Crothers Date: Mon, 27 Jul 2026 10:04:30 -0400 Subject: [PATCH 01/11] =?UTF-8?q?tui:=20add=20the=20theme=20package=20?= =?UTF-8?q?=E2=80=94=20design=20tokens?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce the two-layer color system the rest of the shell is built on: a raw Palette of ten neutrals plus semantic hues, resolved into Tokens that name roles (Background, TextSubtle, BorderActive, Divider) rather than values. Callers ask for a role; the active theme decides what it looks like. Both ramps are deliberately neutral — max channel spread 17 — because the first attempt tinted the greys blue and a divider at 1.2:1 against its background was invisible. Divider is its own token at 2.79:1 rather than reusing a border color. Mix interpolates in Oklab, not sRGB. A green-to-red gauge blended in sRGB passes through a muddy olive with a visibly dark midpoint; Oklab keeps perceived lightness constant across the ramp, which is what makes the context meter read as a smooth scale. Adds the Charm v2 dependencies. Note the module path is charm.land/..., not github.com/charmbracelet/... — v2 moved. --- go.mod | 15 + go.sum | 36 ++ internal/tui/theme/CLAUDE.md | 39 +++ internal/tui/theme/README.md | 65 ++++ internal/tui/theme/doc.go | 12 + internal/tui/theme/export_test.go | 5 + internal/tui/theme/theme.go | 529 ++++++++++++++++++++++++++++++ internal/tui/theme/theme_test.go | 506 ++++++++++++++++++++++++++++ 8 files changed, 1207 insertions(+) create mode 100644 internal/tui/theme/CLAUDE.md create mode 100644 internal/tui/theme/README.md create mode 100644 internal/tui/theme/doc.go create mode 100644 internal/tui/theme/export_test.go create mode 100644 internal/tui/theme/theme.go create mode 100644 internal/tui/theme/theme_test.go diff --git a/go.mod b/go.mod index 6de78e8..cc7802f 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,9 @@ module github.com/pluggableharness/agent go 1.26 require ( + charm.land/bubbletea/v2 v2.0.8 + charm.land/lipgloss/v2 v2.0.5 + github.com/charmbracelet/x/ansi v0.11.7 github.com/hashicorp/go-hclog v1.6.3 github.com/hashicorp/go-plugin v1.8.0 github.com/hashicorp/hcl/v2 v2.24.0 @@ -41,6 +44,13 @@ require ( github.com/apparentlymart/go-textseg/v17 v17.0.1 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/charmbracelet/colorprofile v0.4.3 // indirect + github.com/charmbracelet/ultraviolet v0.0.0-20260703014108-f5a850f9c2b7 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/charmbracelet/x/termios v0.1.1 // indirect + github.com/charmbracelet/x/windows v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.11.0 // indirect + github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/fatih/color v1.13.0 // indirect github.com/go-logr/logr v1.4.3 // indirect @@ -49,12 +59,17 @@ require ( github.com/google/uuid v1.6.0 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect github.com/hashicorp/yamux v0.1.2 // indirect + github.com/lucasb-eyer/go-colorful v1.4.0 // indirect github.com/mattn/go-colorable v0.1.12 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.24 // indirect github.com/mitchellh/go-wordwrap v1.0.1 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect github.com/oklog/run v1.1.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect golang.org/x/mod v0.37.0 // indirect golang.org/x/net v0.56.0 // indirect diff --git a/go.sum b/go.sum index 474305f..67afca7 100644 --- a/go.sum +++ b/go.sum @@ -1,15 +1,39 @@ +charm.land/bubbletea/v2 v2.0.8 h1:SxTJMhCAI3lbPmy4SgX5LWZ24AdINr4I6UEqzZvYJuY= +charm.land/bubbletea/v2 v2.0.8/go.mod h1:2SkdgoTXluXJHOUwAoRlRXF/28vklb1rFl6GcgV1/ss= +charm.land/lipgloss/v2 v2.0.5 h1:kbNxgeeUOYv5J0YdpxFjfvf3dFvqH8Aci4zB6xqFtrY= +charm.land/lipgloss/v2 v2.0.5/go.mod h1:9oqhxt4yxIMe6q5A4kHr44DremZk7J9UNh74GlWa5nc= github.com/agext/levenshtein v1.2.1 h1:QmvMAjj2aEICytGiWzmxoE0x2KZvE0fvmqMOfy2tjT8= github.com/agext/levenshtein v1.2.1/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558= github.com/apparentlymart/go-textseg/v15 v15.0.0 h1:uYvfpb3DyLSCGWnctWKGj857c6ew1u1fNQOlOtuGxQY= github.com/apparentlymart/go-textseg/v15 v15.0.0/go.mod h1:K8XmNZdhEBkdlyDdvbmmsvpAG721bKi0joRfFdHIWJ4= github.com/apparentlymart/go-textseg/v17 v17.0.1 h1:bpMXRgQ5cEoRNuQke1a80/Nl6w3G5eoIbWo9f3gXkAs= github.com/apparentlymart/go-textseg/v17 v17.0.1/go.mod h1:fa8X4jgGeevslICIY6LcdjkSecWnXmYd9Lk34z/VxZs= +github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o= +github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w= github.com/bufbuild/protocompile v0.14.1 h1:iA73zAf/fyljNjQKwYzUHD6AD4R8KMasmwa/FBatYVw= github.com/bufbuild/protocompile v0.14.1/go.mod h1:ppVdAIhbr2H8asPk6k4pY7t9zB1OU5DoEw9xY/FUi1c= github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= +github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= +github.com/charmbracelet/ultraviolet v0.0.0-20260703014108-f5a850f9c2b7 h1:3FmWoGNWK4STvqg0O0Aeav2T7rodWJAPeF0QpH+8gFw= +github.com/charmbracelet/ultraviolet v0.0.0-20260703014108-f5a850f9c2b7/go.mod h1:f/jRa757WUmaOZrbPspXymbg/GnbF+rwe4OLsG7aXYo= +github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI= +github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ= +github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA= +github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= +github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= +github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM= +github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k= +github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= +github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= +github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= +github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -46,6 +70,8 @@ github.com/hashicorp/yamux v0.1.2 h1:XtB8kyFOyHXYVFnwT5C3+Bdo8gArse7j2AQ0DA0Uey8 github.com/hashicorp/yamux v0.1.2/go.mod h1:C+zze2n6e/7wshOZep2A70/aQU6QBRWJO/G6FT1wIns= github.com/jhump/protoreflect v1.17.0 h1:qOEr613fac2lOuTgWN4tPAtLL7fUSbuJL5X5XumQh94= github.com/jhump/protoreflect v1.17.0/go.mod h1:h9+vUUL38jiBzck8ck+6G/aeMX8Z4QUY/NiJPwPNi+8= +github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4= +github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= github.com/mattn/go-colorable v0.1.12 h1:jF+Du6AlPIjs2BiUiQlKOX0rt3SujHxPnksPKZbaA40= github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= @@ -53,8 +79,12 @@ github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Ky github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= 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-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/ay2DU= +github.com/mattn/go-runewidth v0.0.24/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0= github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/oklog/run v1.1.0 h1:GEenZ1cK0+q0+wsJew9qUg/DyD8k3JzYsZAi5gYi2mA= @@ -66,10 +96,14 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +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/zclconf/go-cty v1.19.0 h1:IV8WdqYZc2c5rLX9bEoLNXKojBAp0MZPBHMIrCoa/s4= github.com/zclconf/go-cty v1.19.0/go.mod h1:12W89jGn3JCOIQi7infWr9m80rOkb5RNYJqXMZcN4c8= github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940 h1:4r45xpDWB6ZMSMNJFMOjqrGHynW3DIBuR2H9j0ug+Mo= @@ -122,6 +156,8 @@ go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpu go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +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/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= diff --git a/internal/tui/theme/CLAUDE.md b/internal/tui/theme/CLAUDE.md new file mode 100644 index 0000000..169d45f --- /dev/null +++ b/internal/tui/theme/CLAUDE.md @@ -0,0 +1,39 @@ +# internal/tui/theme — agent notes + +## Palette and Tokens are two layers on purpose + +`Palette` is raw values named for what they are; `Tokens` names them by role. Only `tokens()` bridges the two. Do not let a `Palette` field leak outside this package, and do not add a color to `Tokens` by inlining a hex literal — add the value to `Palette` and map it. + +Both built-in themes route through `New`, so a new derived style is added once and both get it. A theme constructed by hand somewhere else will drift. + +## Unset style is not the same as `TEXT_STYLE_NORMAL` + +`TextNode.style` is optional. A nil pointer means "frontend's own default" (`Theme.Default`); an explicit `TEXT_STYLE_NORMAL` is a producer deliberately asking for plain styling (`Theme.Normal`). The spec calls these out as distinct states, so they stay separate fields even though both built-ins render them identically. A test asserts they remain independently overridable. + +## Unknown enum values must fall back, never panic or drop + +`TextStyle` has a `default:` branch returning `Theme.Default`, for a value added to the enum after this build shipped. The protocol requires rendering text a frontend has no visual treatment for rather than dropping it. + +## Content styles must never set a background + +Lip Gloss ends every styled run with a full SGR reset, which clears any background its container had set. A text style carrying its own background paints a band that stops where the text stops and drops the rest of the row onto the terminal background — a visible patchwork across every pane. This was a real bug, not a hypothetical. + +Surfaces are painted by the container that owns them; text contributes color only. `TestContentStylesSetNoBackground` enforces this across both themes — if it fails, do not "fix" the test. + +The one deliberate exception is `ActionFocused`: a filled control is a single self-contained run whose reset lands at its own end, so it cannot bleed. `ui.Badge` is filled for the same reason. + +Note that `GetBackground()` on an unset style returns a zero color value, not nil — compare against `lipgloss.NewStyle().GetBackground()`. + +## Neutrals must stay grey, and dividers must stay visible + +Three tests guard the palette and they are worth understanding before changing a hex value: + +- `TestNeutralsAreNearlyGrey` — every neutral stays within a channel spread of 24. It measures absolute spread rather than HSV saturation because saturation is meaningless near black: a six-point spread at `#101216` is a quarter of the maximum channel and completely invisible. +- `TestDividerIsVisibleAgainstTheBackground` — asserts from both ends: the divider must be legible as a line (>2:1) *and* stay subordinate to the dimmest text. +- `TestTextContrastIsLegible` — text clears 7:1, subtle text 3:1. + +`Divider` is deliberately a separate token from `Border`. A border frames a region and reads as structure when dim; an inline separator glyph needs more contrast. Do not collapse them. + +## This package is pure domain + +No `log/slog`, no `internal/telemetry`, no I/O — the pure-domain exemption in `.claude/rules/logging-telemetry.md` applies. It is 100%-covered; keep it there. diff --git a/internal/tui/theme/README.md b/internal/tui/theme/README.md new file mode 100644 index 0000000..93d4030 --- /dev/null +++ b/internal/tui/theme/README.md @@ -0,0 +1,65 @@ +# internal/tui/theme + +The shell's design tokens: colors, the spacing scale, and the border presets. This is the bottom of the UI dependency chain — it imports nothing from the rest of the shell, and everything else consumes it. + +## Two color layers + +The split is the same one any design system makes, and it is the reason a theme can be swapped without touching a line of painting code. + +- **`Palette`** — the raw values, named for what they are: a ten-step neutral ramp from app background to strongest text, plus six intent hues. Nothing outside this package references a `Palette` field. +- **`Tokens`** — the semantic layer, naming every color by its *role*. This is the only color vocabulary the rest of the shell sees. + +`tokens()` is the single function that decides which ramp step means "panel" or "muted text", so both built-in themes stay structurally identical and only their colors differ. + +### The token set + +| Group | Tokens | Why three | +|---|---|---| +| Surfaces | `Background`, `BackgroundPanel`, `BackgroundElement` | The application surface, plus two fills reserved for self-contained controls (see below) | +| Text | `Text`, `TextMuted`, `TextSubtle`, `OnAccent` | Primary, secondary, and de-emphasized, plus text on a filled accent | +| Lines | `BorderSubtle`, `Border`, `BorderActive`, `Divider` | Quiet edges, ordinary pane edges, focus — and inline separators, which need more contrast than a border | +| Intents | `Primary`, `Accent`, `Success`, `Warning`, `Danger`, `Info` | | +| Diff | `DiffAdded`, `DiffRemoved`, `DiffContext`, `DiffHunkHeader` | | + +## Neutrals are actually neutral + +The ramp keeps only a hint of cool. An earlier version carried a third of its value in blue — `#4a5570` is periwinkle, not grey — and at low luminance that cast is the first thing the eye notices, so borders and separators stopped reading as quiet structure and started reading as dark blue lines. `TestNeutralsAreNearlyGrey` holds every neutral to a channel spread of 24 or less. + +That test measures **absolute channel spread, not HSV saturation**, and the distinction matters: near black a six-point spread is a quarter of the maximum channel yet completely imperceptible, so saturation flags colors that look perfectly neutral while catching nothing that matters. + +## A divider is not a border + +`Divider` exists separately from `Border` because the two have different jobs. A border frames a region and reads as structure even when dim; a lone `│` between two fields sits among text and needs more contrast to register at all. Sharing one token left the separators effectively invisible — 1.2:1 against the background. + +Current contrast against the background: divider 2.8:1, border 1.9:1, subtle text 4.8:1, text 12.6:1. The divider is visible as a line while staying subordinate to the dimmest text, which is what `TestDividerIsVisibleAgainstTheBackground` asserts from both ends. + +## Tone — naming a role, not a color + +`Tone` is the selector configuration uses when it wants to say "this thing is amber" without naming a hex value: `color = "warning"` resolves through `ToneByName` and then `Theme.Tone`, so a custom theme recolors everything that referenced it. The agent roster in `internal/tui/shell` is the first consumer. + +## Ramps and blending + +`Ramp` is an ordered list of tone *roles* a gauge interpolates across — `{ToneSuccess, ToneWarning, ToneDanger}` by default, expressible in config as `["success", "warning", "danger"]`. `Ramp.At` resolves a position from 0 to 1, interpolating between the two stops it falls between. + +`Mix` blends two colors, and it is how the theme derives a shade instead of hardcoding one: `Theme.Muted` is a token mixed toward `Background`, and an intermediate ramp hue is one stop mixed toward the next. Both stay expressed in terms of tokens, so a custom theme recolors them too. This is the sanctioned way to produce a color that is not itself a token — never a literal at a call site. + +## The non-color half + +Spacing is a scale (`Space0`..`Space4`) plus `Gutter`, the breathing room between the screen edge and the outermost pane. Borders are presets (`BorderNone`, `BorderNormal`, `BorderRounded`, `BorderThick`). Call sites pick a step; they do not invent a number. + +## One surface + +What makes the UI read as paneled is borders, titles, and spacing — not competing background colors. The shell paints a single application surface, set once on the Bubble Tea `View`. + +That is a correctness rule before a stylistic one: Lip Gloss ends every styled run with a full SGR reset, which clears whatever background its container set, so a broadly-filled region only stays filled until the first styled run inside it ends. `BackgroundPanel` and `BackgroundElement` therefore exist for genuinely filled, self-contained controls — a badge, a focused button — not for regions. + +## Derived styles + +`Theme` also carries the Lip Gloss styles the painter uses, derived from the tokens in `New` so the two can never disagree. **Content styles set a foreground and never a background**, for the reason above; `ActionFocused` is the one deliberate exception. + +`TextStyle` maps the protocol's `TextStyle` enum onto those styles, including the two cases the spec distinguishes (unset versus explicit `NORMAL`) and the graceful fallback for a value added to the enum after this build shipped. + +## Related + +- `internal/tui/ui` — the utility and component layer built on these tokens. +- [`docs/first-party/frontends/tui.md`](../../../docs/first-party/frontends/tui.md) — the design system in prose. diff --git a/internal/tui/theme/doc.go b/internal/tui/theme/doc.go new file mode 100644 index 0000000..9c8fd88 --- /dev/null +++ b/internal/tui/theme/doc.go @@ -0,0 +1,12 @@ +// Package theme owns the reference TUI shell's style tokens: the mapping from +// the protocol's TextStyle vocabulary +// (docs/specifications/frontend/render-tree.md) plus the shell's own chrome +// roles onto concrete Lip Gloss styles. +// +// The package is deliberately a token table rather than a styling engine. Every +// visual decision the shell makes resolves to one of the fields on Theme, so a +// future config-driven theme can be added by constructing a different Theme +// without touching the painter. Nothing here performs I/O or inspects the +// terminal; profile downsampling for 16-color and monochrome terminals is Lip +// Gloss's job, so tokens are authored once in truecolor. +package theme diff --git a/internal/tui/theme/export_test.go b/internal/tui/theme/export_test.go new file mode 100644 index 0000000..bed59e2 --- /dev/null +++ b/internal/tui/theme/export_test.go @@ -0,0 +1,5 @@ +package theme + +// ExportToOklab exposes the Oklab conversion to this package's external tests, +// which need it to assert that a gradient's lightness behaves. +var ExportToOklab = toOklab diff --git a/internal/tui/theme/theme.go b/internal/tui/theme/theme.go new file mode 100644 index 0000000..27292e3 --- /dev/null +++ b/internal/tui/theme/theme.go @@ -0,0 +1,529 @@ +package theme + +import ( + "image/color" + "math" + + "charm.land/lipgloss/v2" + + renderv1 "github.com/pluggableharness/agent/pkg/render/proto/v1" +) + +// Palette is the raw color layer: the literal values a theme is built from, +// named for what they are rather than what they are used for. +// +// Nothing outside this package should reference a Palette field directly. It +// exists to be mapped onto Tokens, exactly as a design system keeps its raw +// ramp separate from the semantic names that reference it — the palette answers +// "what colors exist", Tokens answers "what do we use them for". +type Palette struct { + // Neutral is the surface-to-text ramp, darkest first for a dark theme and + // lightest first for a light one. Index 0 is the app background and index 9 + // is the highest-contrast text. + Neutral [10]string + + Primary string + Accent string + Success string + Warning string + Danger string + Info string + + // OnAccent is the text color placed on top of a filled accent surface. + OnAccent string +} + +// Tokens is the semantic color layer: every color the shell paints with, +// named for its role. +// +// What makes the UI read as paneled is borders, titles, and spacing — not +// competing surface colors. Background is the whole application surface and is +// set once on the Bubble Tea View; BackgroundPanel and BackgroundElement exist +// for genuinely filled, self-contained controls (a badge, a focused button), +// not for broad regions. +// +// The reason is mechanical rather than aesthetic. Lip Gloss ends every styled +// run with a full SGR reset, which clears any background a container set, so a +// broadly-filled region only stays filled until the first styled run inside it +// ends. Filling large areas is therefore unreliable, and a single flat +// application surface is both correct and calmer to look at. See New for the +// rule this places on content styles. +type Tokens struct { + Background color.Color + BackgroundPanel color.Color + BackgroundElement color.Color + + Text color.Color + TextMuted color.Color + TextSubtle color.Color + OnAccent color.Color + + Border color.Color + BorderSubtle color.Color + BorderActive color.Color + // Divider is for inline separators between fields, which have a different + // job from a panel edge and therefore a different weight. A border frames a + // region and reads as structure even when dim; a lone separator glyph sits + // among text and needs more contrast to register at all. Sharing one token + // between them leaves one of the two wrong. + Divider color.Color + + Primary color.Color + Accent color.Color + Success color.Color + Warning color.Color + Danger color.Color + Info color.Color + + DiffAdded color.Color + DiffRemoved color.Color + DiffContext color.Color + DiffHunkHeader color.Color +} + +// Tone names a color role rather than a color. It is the selector a +// configuration file uses when it wants to say "this thing is amber" without +// naming a hex value the theme should own — `color = "warning"` resolves +// through ToneByName and then through Theme.Tone, so a custom theme recolors +// everything that referenced it. +type Tone int + +// The tone roles, in the order their config-facing names are declared below. +const ( + // ToneNeutral is the default, muted role. + ToneNeutral Tone = iota + // TonePrimary is the main accent used for focus and interaction. + TonePrimary + // ToneAccent is the secondary accent. + ToneAccent + // ToneSuccess marks a positive or completed state. + ToneSuccess + // ToneWarning marks something worth attention, short of an error. + ToneWarning + // ToneDanger marks a failure or a destructive action. + ToneDanger + // ToneInfo marks neutral, informational emphasis. + ToneInfo +) + +// toneNames is the config-facing spelling of each tone, in declaration order. +var toneNames = [...]string{"neutral", "primary", "accent", "success", "warning", "danger", "info"} + +// String returns the tone's config-facing name. +func (t Tone) String() string { + if int(t) < 0 || int(t) >= len(toneNames) { + return toneNames[ToneNeutral] + } + + return toneNames[t] +} + +// ToneByName resolves a configured tone name. An unknown name resolves to +// ToneNeutral with ok false, so a caller can report the bad value rather than +// failing startup over a cosmetic setting. +func ToneByName(name string) (Tone, bool) { + for i, n := range toneNames { + if n == name { + return Tone(i), true + } + } + + return ToneNeutral, false +} + +// Tone resolves a role to this theme's color for it. +func (t Theme) Tone(tone Tone) color.Color { + switch tone { + case TonePrimary: + return t.C.Primary + case ToneAccent: + return t.C.Accent + case ToneSuccess: + return t.C.Success + case ToneWarning: + return t.C.Warning + case ToneDanger: + return t.C.Danger + case ToneInfo: + return t.C.Info + case ToneNeutral: + return t.C.TextMuted + default: + return t.C.TextMuted + } +} + +// Mix blends two colors, with t running from 0 (all a) to 1 (all b). +// +// This is how the theme derives a shade instead of hardcoding one: a muted +// variant is the token mixed toward Background, and a gauge's intermediate hue +// is one ramp stop mixed toward the next. Both stay expressed in terms of +// tokens, so a custom theme recolors them along with everything else — which is +// the whole reason the palette layer exists. +// +// The blend happens in Oklab rather than in sRGB. Interpolating sRGB channels +// directly is the obvious implementation and it looks wrong: the path from +// green to red passes through a muddy olive, the midpoint is noticeably darker +// than either end, and a gradient built from it bands visibly because equal +// numeric steps are not equal perceptual steps. Oklab is designed so that equal +// distances look equal, which is exactly what a gradient needs. +func Mix(a, b color.Color, t float64) color.Color { + t = math.Min(math.Max(t, 0), 1) + + al, aa, ab2 := toOklab(a) + bl, ba, bb2 := toOklab(b) + + return fromOklab( + al+(bl-al)*t, + aa+(ba-aa)*t, + ab2+(bb2-ab2)*t, + ) +} + +// srgbToLinear removes the sRGB transfer function. +func srgbToLinear(c float64) float64 { + if c <= 0.04045 { + return c / 12.92 + } + + return math.Pow((c+0.055)/1.055, 2.4) +} + +// linearToSrgb reapplies it. +func linearToSrgb(c float64) float64 { + if c <= 0.0031308 { + return c * 12.92 + } + + return 1.055*math.Pow(c, 1/2.4) - 0.055 +} + +// toOklab converts a color into Oklab, the perceptually uniform space this +// theme interpolates in. The matrices are Björn Ottosson's published values. +func toOklab(c color.Color) (lightness, greenRed, blueYellow float64) { + r32, g32, b32, _ := c.RGBA() + r := srgbToLinear(float64(r32>>8) / 255) + g := srgbToLinear(float64(g32>>8) / 255) + b := srgbToLinear(float64(b32>>8) / 255) + + l := math.Cbrt(0.4122214708*r + 0.5363325363*g + 0.0514459929*b) + m := math.Cbrt(0.2119034982*r + 0.6806995451*g + 0.1073969566*b) + s := math.Cbrt(0.0883024619*r + 0.2817188376*g + 0.6299787005*b) + + return 0.2104542553*l + 0.7936177850*m - 0.0040720468*s, + 1.9779984951*l - 2.4285922050*m + 0.4505937099*s, + 0.0259040371*l + 0.7827717662*m - 0.8086757660*s +} + +// fromOklab is the inverse, clamped back into displayable sRGB. +func fromOklab(lightness, greenRed, blueYellow float64) color.Color { + l := lightness + 0.3963377774*greenRed + 0.2158037573*blueYellow + m := lightness - 0.1055613458*greenRed - 0.0638541728*blueYellow + s := lightness - 0.0894841775*greenRed - 1.2914855480*blueYellow + + l, m, s = l*l*l, m*m*m, s*s*s + + channel := func(v float64) uint8 { + return uint8(math.Round(math.Min(math.Max(linearToSrgb(v), 0), 1) * 255)) + } + + return color.RGBA{ + R: channel(4.0767416621*l - 3.3077115913*m + 0.2309699292*s), + G: channel(-1.2684380046*l + 2.6097574011*m - 0.3413193965*s), + B: channel(-0.0041960863*l - 0.7034186147*m + 1.7076147010*s), + A: 0xff, + } +} + +// MutedMix is how far a muted variant is blended toward the background. Enough +// to read as "not active" without becoming invisible. +const MutedMix = 0.55 + +// Muted returns a dimmed variant of a color, blended toward this theme's +// background. +func (t Theme) Muted(c color.Color) color.Color { return Mix(c, t.C.Background, MutedMix) } + +// Ramp is an ordered list of tone stops a gauge interpolates across. +// +// It is a list of *roles*, not colors, so configuration can express a ramp as +// `gauge_ramp = ["success", "warning", "danger"]` and the active theme decides +// what those look like. +type Ramp []Tone + +// DefaultGaugeRamp runs green through amber to red — the universally read +// pressure ramp. +var DefaultGaugeRamp = Ramp{ToneSuccess, ToneWarning, ToneDanger} + +// At resolves the ramp at position f, from 0 to 1, interpolating between the +// two stops it falls between. An empty ramp resolves to the muted text token so +// a misconfigured ramp degrades to something visible rather than to nothing. +func (r Ramp) At(t Theme, f float64) color.Color { + switch len(r) { + case 0: + return t.C.TextMuted + case 1: + return t.Tone(r[0]) + } + + f = math.Min(math.Max(f, 0), 1) + + // Position along the ramp in stop-index space; the fractional part is how + // far between this stop and the next. + pos := f * float64(len(r)-1) + i := int(pos) + + if i >= len(r)-1 { + return t.Tone(r[len(r)-1]) + } + + return Mix(t.Tone(r[i]), t.Tone(r[i+1]), pos-float64(i)) +} + +// Spacing scale, in terminal cells. Every pad, gap, and gutter in the shell +// uses one of these rather than a literal, which is what keeps rhythm +// consistent across panes written at different times. +const ( + Space0 = 0 + Space1 = 1 + Space2 = 2 + Space3 = 3 + Space4 = 4 +) + +// Gutter is the breathing room between the screen edge and the outermost pane. +const Gutter = Space1 + +// Border presets. The shell picks from this set rather than calling Lip Gloss +// border constructors at the point of use, so a change of border language is +// one edit here. +var ( + BorderNone = lipgloss.HiddenBorder() + BorderNormal = lipgloss.NormalBorder() + BorderRounded = lipgloss.RoundedBorder() + BorderThick = lipgloss.ThickBorder() +) + +// Theme is a resolved theme: its semantic tokens plus the Lip Gloss styles the +// painter uses, derived from those tokens so the two can never disagree. +type Theme struct { + Name string + C Tokens + // GaugeRamp is the ramp a pressure gauge interpolates across. + GaugeRamp Ramp + + // Text-style tokens, one per TextStyle enum value plus the unset case. + Default lipgloss.Style + Normal lipgloss.Style + Bold lipgloss.Style + Italic lipgloss.Style + Code lipgloss.Style + Dim lipgloss.Style + Error lipgloss.Style + Warning lipgloss.Style + Success lipgloss.Style + + // Chrome roles owned by the shell rather than by the protocol. + CodeBlock lipgloss.Style + Border lipgloss.Style + BorderFocused lipgloss.Style + RegionTitle lipgloss.Style + Action lipgloss.Style + ActionFocused lipgloss.Style + DiffAdd lipgloss.Style + DiffRemove lipgloss.Style + DiffHeader lipgloss.Style + TableHeader lipgloss.Style + Link lipgloss.Style + SubSession lipgloss.Style +} + +// TextStyle resolves a TextNode's optional style pointer to a concrete style. +// A nil pointer means the producer left style unset and gets Default; an +// explicit TEXT_STYLE_NORMAL gets Normal. Any value this build does not +// recognize — including one added to the enum after this shell shipped — falls +// back to Default rather than being dropped, which is what +// docs/specifications/frontend/render-tree.md requires of every frontend. +func (t Theme) TextStyle(style *renderv1.TextStyle) lipgloss.Style { + if style == nil { + return t.Default + } + + switch *style { + case renderv1.TextStyle_TEXT_STYLE_NORMAL: + return t.Normal + case renderv1.TextStyle_TEXT_STYLE_BOLD: + return t.Bold + case renderv1.TextStyle_TEXT_STYLE_ITALIC: + return t.Italic + case renderv1.TextStyle_TEXT_STYLE_CODE: + return t.Code + case renderv1.TextStyle_TEXT_STYLE_DIM: + return t.Dim + case renderv1.TextStyle_TEXT_STYLE_ERROR: + return t.Error + case renderv1.TextStyle_TEXT_STYLE_WARNING: + return t.Warning + case renderv1.TextStyle_TEXT_STYLE_SUCCESS: + return t.Success + case renderv1.TextStyle_TEXT_STYLE_UNSPECIFIED: + return t.Default + default: + return t.Default + } +} + +// DarkPalette is the raw ramp behind Dark. +var DarkPalette = Palette{ + // Near-neutral greys with only a hint of cool. An earlier ramp carried a + // third of its value in blue — #4a5570 is periwinkle, not grey — and at low + // luminance that cast is the first thing the eye picks up, so borders and + // separators read as "dark blue lines" rather than as quiet structure. + // Saturation here stays under roughly a fifth at every step. + Neutral: [10]string{ + "#101216", // 0 app background + "#16181d", // 1 panel + "#1d2026", // 2 element + "#2b2f36", // 3 subtle border + "#3d424b", // 4 border + "#565c67", // 5 divider + "#7b828e", // 6 subtle text + "#a0a7b3", // 7 muted text + "#ced4dd", // 8 text + "#eef1f5", // 9 strong text + }, + Primary: "#7aa2f7", + Accent: "#bb9af7", + Success: "#9ece6a", + Warning: "#e0af68", + Danger: "#f7768e", + Info: "#7dcfff", + OnAccent: "#101216", +} + +// LightPalette is the raw ramp behind Light. +var LightPalette = Palette{ + Neutral: [10]string{ + "#fcfcfd", // 0 app background + "#f4f5f7", // 1 panel + "#eaebee", // 2 element + "#dee0e4", // 3 subtle border + "#c6c9cf", // 4 border + "#9ca0a9", // 5 divider + "#767a84", // 6 subtle text + "#585c66", // 7 muted text + "#2c2f36", // 8 text + "#171a1f", // 9 strong text + }, + Primary: "#2f5ea8", + Accent: "#7048b6", + Success: "#3a6f22", + Warning: "#8a6100", + Danger: "#b02a44", + Info: "#1c6f96", + OnAccent: "#fbfcfe", +} + +// tokens maps a raw palette onto the semantic layer. This is the single place +// that decides which ramp step means "panel" or "muted text", so both built-in +// themes stay structurally identical and only their colors differ. +func tokens(p Palette) Tokens { + n := func(i int) color.Color { return lipgloss.Color(p.Neutral[i]) } + + return Tokens{ + Background: n(0), + BackgroundPanel: n(1), + BackgroundElement: n(2), + + Text: n(8), + TextMuted: n(7), + TextSubtle: n(6), + OnAccent: lipgloss.Color(p.OnAccent), + + BorderSubtle: n(3), + Border: n(4), + Divider: n(5), + BorderActive: lipgloss.Color(p.Primary), + + Primary: lipgloss.Color(p.Primary), + Accent: lipgloss.Color(p.Accent), + Success: lipgloss.Color(p.Success), + Warning: lipgloss.Color(p.Warning), + Danger: lipgloss.Color(p.Danger), + Info: lipgloss.Color(p.Info), + + DiffAdded: lipgloss.Color(p.Success), + DiffRemoved: lipgloss.Color(p.Danger), + DiffContext: n(7), + DiffHunkHeader: n(6), + } +} + +// New assembles a Theme from a raw palette. Both built-in themes route through +// here, so a new derived style is added once and both get it. +// +// Content styles set a foreground and never a background. This is a +// correctness rule, not a preference: Lip Gloss terminates every styled run +// with a full SGR reset, and a reset inside a container clears the container's +// background for everything after it. A text style that set its own background +// therefore paints a band that ends wherever the text ends, leaving the rest of +// the row on the terminal's background — which is precisely the patchwork this +// rule exists to prevent. Surfaces are painted by the container that owns them; +// text only ever contributes color. +// +// The two deliberate exceptions are Action and ActionFocused, which are filled +// controls rather than runs of text: each is a single self-contained run whose +// reset lands at its own end, so it cannot bleed into anything. +func New(name string, p Palette) Theme { + c := tokens(p) + + fg := func(v color.Color) lipgloss.Style { return lipgloss.NewStyle().Foreground(v) } + base := fg(c.Text) + + return Theme{ + Name: name, + C: c, + GaugeRamp: DefaultGaugeRamp, + Default: base, + Normal: base, + Bold: base.Bold(true), + Italic: base.Italic(true), + Code: fg(c.Info), + Dim: fg(c.TextSubtle), + Error: fg(c.Danger).Bold(true), + Warning: fg(c.Warning), + Success: fg(c.Success), + + CodeBlock: fg(c.TextMuted), + Border: fg(c.Border), + BorderFocused: fg(c.BorderActive), + RegionTitle: fg(c.TextMuted).Bold(true), + Action: fg(c.Primary), + ActionFocused: lipgloss.NewStyle().Foreground(c.OnAccent).Background(c.Primary).Bold(true), + DiffAdd: fg(c.DiffAdded), + DiffRemove: fg(c.DiffRemoved), + DiffHeader: fg(c.DiffHunkHeader).Bold(true), + TableHeader: fg(c.Text).Bold(true), + Link: fg(c.Info).Underline(true), + SubSession: fg(c.TextMuted).Italic(true), + } +} + +// Dark returns the built-in dark theme. +func Dark() Theme { return New("dark", DarkPalette) } + +// Light returns the built-in light theme. +func Light() Theme { return New("light", LightPalette) } + +// ByName resolves a configured theme name. An unknown name resolves to Dark +// with ok false, so a caller can log the fallback rather than failing startup +// over a cosmetic setting. +func ByName(name string) (Theme, bool) { + switch name { + case "dark", "": + return Dark(), true + case "light": + return Light(), true + default: + return Dark(), false + } +} diff --git a/internal/tui/theme/theme_test.go b/internal/tui/theme/theme_test.go new file mode 100644 index 0000000..92fdcbb --- /dev/null +++ b/internal/tui/theme/theme_test.go @@ -0,0 +1,506 @@ +package theme_test + +import ( + "image/color" + "math" + "testing" + + "charm.land/lipgloss/v2" + + "github.com/pluggableharness/agent/internal/tui/theme" + renderv1 "github.com/pluggableharness/agent/pkg/render/proto/v1" +) + +func TestTextStyleMapsEveryEnumValue(t *testing.T) { + t.Parallel() + + th := theme.Dark() + + // Every enum value must resolve to a style that renders its input. The + // protocol requires a frontend with no visual distinction for a style to + // still render the underlying text rather than dropping it. + for value, name := range renderv1.TextStyle_name { + style := renderv1.TextStyle(value) + got := th.TextStyle(&style).Render("payload") + + if got == "" { + t.Errorf("TextStyle(%s) rendered empty, dropping content", name) + } + } +} + +func TestTextStyleUnsetIsDistinctFromNormal(t *testing.T) { + t.Parallel() + + th := theme.Dark() + th.Normal = th.Normal.Bold(true) + + normal := renderv1.TextStyle_TEXT_STYLE_NORMAL + + unset := th.TextStyle(nil).Render("x") + explicit := th.TextStyle(&normal).Render("x") + + // Unset means "frontend's own default"; an explicit NORMAL is a producer + // deliberately asking for plain styling. They are separate states and the + // theme must keep them separately overridable. + if unset == explicit { + t.Fatalf("unset style and explicit NORMAL resolved identically after overriding Normal") + } +} + +func TestTextStyleUnknownValueFallsBackToDefault(t *testing.T) { + t.Parallel() + + th := theme.Dark() + future := renderv1.TextStyle(9999) + + got := th.TextStyle(&future).Render("from the future") + want := th.Default.Render("from the future") + + if got != want { + t.Fatalf("unknown TextStyle did not fall back to Default\ngot: %q\nwant: %q", got, want) + } +} + +func TestByName(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + in string + wantName string + wantOK bool + }{ + {name: "dark", in: "dark", wantName: "dark", wantOK: true}, + {name: "light", in: "light", wantName: "light", wantOK: true}, + {name: "empty defaults to dark", in: "", wantName: "dark", wantOK: true}, + {name: "unknown falls back", in: "solarized", wantName: "dark", wantOK: false}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + got, ok := theme.ByName(tc.in) + if ok != tc.wantOK { + t.Errorf("ByName(%q) ok = %v, want %v", tc.in, ok, tc.wantOK) + } + + if got.Name != tc.wantName { + t.Errorf("ByName(%q) name = %q, want %q", tc.in, got.Name, tc.wantName) + } + }) + } +} + +func TestBuiltinThemesDifferAndAreNamed(t *testing.T) { + t.Parallel() + + dark, light := theme.Dark(), theme.Light() + + if dark.Name != "dark" || light.Name != "light" { + t.Fatalf("themes misnamed: %q / %q", dark.Name, light.Name) + } + + if dark.Default.Render("x") == light.Default.Render("x") { + t.Fatal("dark and light rendered identically; palettes are not being applied") + } +} + +// Content styles must not set a background. +// +// Lip Gloss terminates every styled run with a full SGR reset, which clears any +// background a container had set. A text style carrying its own background +// therefore paints a band that stops where the text stops, leaving the rest of +// the row on the terminal's background — a visible patchwork across every pane. +// Surfaces are painted by the container that owns them; text contributes color +// only. +func TestContentStylesSetNoBackground(t *testing.T) { + t.Parallel() + + // An unset background is a zero color value, not nil, so the comparison is + // against what a fresh style reports rather than against nil. + unset := lipgloss.NewStyle().GetBackground() + + for _, th := range []theme.Theme{theme.Dark(), theme.Light()} { + styles := map[string]lipgloss.Style{ + "Default": th.Default, + "Normal": th.Normal, + "Bold": th.Bold, + "Italic": th.Italic, + "Code": th.Code, + "Dim": th.Dim, + "Error": th.Error, + "Warning": th.Warning, + "Success": th.Success, + "CodeBlock": th.CodeBlock, + "Border": th.Border, + "RegionTitle": th.RegionTitle, + "Action": th.Action, + "DiffAdd": th.DiffAdd, + "DiffRemove": th.DiffRemove, + "DiffHeader": th.DiffHeader, + "TableHeader": th.TableHeader, + "Link": th.Link, + "SubSession": th.SubSession, + } + + for name, s := range styles { + if bg := s.GetBackground(); bg != unset { + t.Errorf("%s theme: style %s sets a background (%v); it would band across its row", th.Name, name, bg) + } + } + } +} + +// The one deliberate exception: a focused action is a filled control, a single +// self-contained run whose reset lands at its own end. +func TestFocusedActionIsDeliberatelyFilled(t *testing.T) { + t.Parallel() + + if theme.Dark().ActionFocused.GetBackground() == lipgloss.NewStyle().GetBackground() { + t.Fatal("ActionFocused lost its fill; a focused button needs to be visibly inverted") + } +} + +func TestToneRoundTripsByName(t *testing.T) { + t.Parallel() + + for _, name := range []string{"neutral", "primary", "accent", "success", "warning", "danger", "info"} { + tone, ok := theme.ToneByName(name) + if !ok { + t.Errorf("ToneByName(%q) not found", name) + + continue + } + + if got := tone.String(); got != name { + t.Errorf("tone %q round-tripped to %q", name, got) + } + } +} + +func TestUnknownToneFallsBack(t *testing.T) { + t.Parallel() + + tone, ok := theme.ToneByName("chartreuse") + if ok { + t.Fatal("ToneByName accepted an unknown name") + } + + if tone != theme.ToneNeutral { + t.Fatalf("unknown tone = %v, want ToneNeutral", tone) + } + + if got := theme.Tone(99).String(); got != "neutral" { + t.Fatalf("out-of-range tone name = %q, want neutral", got) + } +} + +// Every tone must resolve to a color, including an out-of-range value. +func TestToneResolvesToDistinctColors(t *testing.T) { + t.Parallel() + + th := theme.Dark() + seen := map[uint32]string{} + + tones := map[theme.Tone]string{ + theme.TonePrimary: "primary", + theme.ToneAccent: "accent", + theme.ToneSuccess: "success", + theme.ToneWarning: "warning", + theme.ToneDanger: "danger", + theme.ToneInfo: "info", + } + + for tone, name := range tones { + r, g, b, _ := th.Tone(tone).RGBA() + key := r<<16 | g<<8 | b + + if other, dup := seen[key]; dup { + t.Errorf("tones %s and %s resolve to the same color", name, other) + } + + seen[key] = name + } + + if th.Tone(theme.Tone(99)) == nil { + t.Fatal("out-of-range tone resolved to nil") + } +} + +func TestMixHitsItsEndpointsAndClamps(t *testing.T) { + t.Parallel() + + black := color.RGBA{A: 0xff} + white := color.RGBA{R: 0xff, G: 0xff, B: 0xff, A: 0xff} + + tests := []struct { + name string + at float64 + want uint8 + }{ + {"all a", 0, 0x00}, + {"all b", 1, 0xff}, + {"below range clamps", -2, 0x00}, + {"above range clamps", 4, 0xff}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + r, _, _, _ := theme.Mix(black, white, tc.at).RGBA() + if got := uint8(r >> 8); got != tc.want { + t.Fatalf("Mix at %v = %#02x, want %#02x", tc.at, got, tc.want) + } + }) + } +} + +// Blending happens in a perceptually uniform space, so equal steps look equal. +// A plain sRGB channel average would put the midpoint of black and white at +// 0x80, which reads far lighter than half — the whole reason gradients built +// that way band and look muddy. +func TestMixIsPerceptuallyUniform(t *testing.T) { + t.Parallel() + + black := color.RGBA{A: 0xff} + white := color.RGBA{R: 0xff, G: 0xff, B: 0xff, A: 0xff} + + mid, _, _, _ := theme.Mix(black, white, 0.5).RGBA() + if got := uint8(mid >> 8); got >= 0x80 { + t.Errorf("perceptual midpoint = %#02x, expected well below the sRGB average 0x80", got) + } + + // Lightness still rises monotonically across the whole range. + prev := -1.0 + + for i := range 21 { + r, _, _, _ := theme.Mix(black, white, float64(i)/20).RGBA() + + if got := float64(r >> 8); got < prev { + t.Fatalf("step %d went backwards: %v after %v", i, got, prev) + } else { + prev = got + } + } +} + +// A gradient across the default ramp must not dip in lightness partway, which +// is what makes an sRGB green-to-red blend look muddy in the middle. +func TestRampGradientDoesNotDipInTheMiddle(t *testing.T) { + t.Parallel() + + th := theme.Dark() + + var minL, endL float64 + + minL = 1 + + for i := range 41 { + l, _, _ := theme.ExportToOklab(theme.DefaultGaugeRamp.At(th, float64(i)/40)) + minL = math.Min(minL, l) + + if i == 40 { + endL = l + } + } + + startL, _, _ := theme.ExportToOklab(theme.DefaultGaugeRamp.At(th, 0)) + + // The dimmest point on the ramp should be one of its ends, not a sag + // somewhere in between. + if minL < math.Min(startL, endL)-0.02 { + t.Errorf("ramp dips to lightness %.3f, below both ends (%.3f, %.3f)", minL, startL, endL) + } +} + +// A muted color must sit between its source and the background — dimmer, but +// still the same hue rather than a new one. +func TestMutedBlendsTowardBackground(t *testing.T) { + t.Parallel() + + th := theme.Dark() + + got := th.Muted(th.C.Success) + if got == th.C.Success { + t.Fatal("Muted returned the original color") + } + + if want := theme.Mix(th.C.Success, th.C.Background, theme.MutedMix); got != want { + t.Fatalf("Muted = %v, want %v", got, want) + } + + // Dimming a dark theme's color moves it toward the dark background, so the + // muted variant is no brighter than the original. + sr, sg, sb, _ := th.C.Success.RGBA() + mr, mg, mb, _ := got.RGBA() + + if mr+mg+mb > sr+sg+sb { + t.Error("muted variant is brighter than its source on a dark theme") + } +} + +func TestRampResolvesStopsAndInterpolates(t *testing.T) { + t.Parallel() + + th := theme.Dark() + ramp := theme.DefaultGaugeRamp + + // Endpoints and the midpoint land exactly on their stops. + for _, tc := range []struct { + at float64 + want color.Color + name string + }{ + {0, th.C.Success, "start"}, + {0.5, th.C.Warning, "middle"}, + {1, th.C.Danger, "end"}, + {-1, th.C.Success, "below range clamps"}, + {2, th.C.Danger, "above range clamps"}, + } { + if got := ramp.At(th, tc.at); got != tc.want { + t.Errorf("%s: At(%v) = %v, want %v", tc.name, tc.at, got, tc.want) + } + } + + // Between stops it blends rather than snapping. + mid := ramp.At(th, 0.25) + if mid == th.C.Success || mid == th.C.Warning { + t.Error("At(0.25) snapped to a stop instead of interpolating") + } +} + +// A degenerate ramp must still resolve to something visible rather than +// failing or returning nil. +func TestDegenerateRampsDegradeGracefully(t *testing.T) { + t.Parallel() + + th := theme.Dark() + + if got := (theme.Ramp{}).At(th, 0.5); got != th.C.TextMuted { + t.Errorf("empty ramp = %v, want the muted text token", got) + } + + single := theme.Ramp{theme.ToneInfo} + for _, at := range []float64{0, 0.5, 1} { + if got := single.At(th, at); got != th.C.Info { + t.Errorf("single-stop ramp at %v = %v, want info", at, got) + } + } +} + +func TestThemesCarryTheDefaultGaugeRamp(t *testing.T) { + t.Parallel() + + for _, th := range []theme.Theme{theme.Dark(), theme.Light()} { + if len(th.GaugeRamp) != len(theme.DefaultGaugeRamp) { + t.Errorf("%s theme has no default gauge ramp", th.Name) + } + } +} + +// relLuminance is the WCAG relative luminance of a color. +func relLuminance(c color.Color) float64 { + r, g, b, _ := c.RGBA() + + channel := func(v uint32) float64 { + x := float64(v>>8) / 255 + if x <= 0.03928 { + return x / 12.92 + } + + return math.Pow((x+0.055)/1.055, 2.4) + } + + return 0.2126*channel(r) + 0.7152*channel(g) + 0.0722*channel(b) +} + +func contrastRatio(a, b color.Color) float64 { + la, lb := relLuminance(a)+0.05, relLuminance(b)+0.05 + + return math.Max(la, lb) / math.Min(la, lb) +} + +// channelSpread is the distance between a color's strongest and weakest +// channel, in 0..255. +// +// This is the right measure for "does this grey look like a hue", and HSV +// saturation is not: near black a six-point spread is a quarter of the maximum +// channel yet completely imperceptible, so saturation flags colors that look +// perfectly neutral while missing nothing that matters. Absolute spread tracks +// what the eye actually notices. +func channelSpread(c color.Color) float64 { + r, g, b, _ := c.RGBA() + hi := math.Max(float64(r>>8), math.Max(float64(g>>8), float64(b>>8))) + lo := math.Min(float64(r>>8), math.Min(float64(g>>8), float64(b>>8))) + + return hi - lo +} + +// The neutral ramp must actually be neutral. An earlier ramp carried a third of +// its value in blue, and at low luminance that cast is the first thing the eye +// picks up — borders and separators stopped reading as quiet structure and +// started reading as dark blue lines. +func TestNeutralsAreNearlyGrey(t *testing.T) { + t.Parallel() + + // #4a5570 — the periwinkle this rule exists to prevent — spreads 38. + const maxSpread = 24.0 + + for _, th := range []theme.Theme{theme.Dark(), theme.Light()} { + neutrals := map[string]color.Color{ + "Background": th.C.Background, + "BackgroundPanel": th.C.BackgroundPanel, + "BackgroundElement": th.C.BackgroundElement, + "BorderSubtle": th.C.BorderSubtle, + "Border": th.C.Border, + "Divider": th.C.Divider, + "TextSubtle": th.C.TextSubtle, + "TextMuted": th.C.TextMuted, + "Text": th.C.Text, + } + + for name, c := range neutrals { + if got := channelSpread(c); got > maxSpread { + t.Errorf("%s theme: %s spreads %.0f across channels, want at most %.0f — it will read as a hue, not a grey", + th.Name, name, got, maxSpread) + } + } + } +} + +// A separator has to be visible as a line. Sharing the near-background subtle +// border token left it effectively invisible. +func TestDividerIsVisibleAgainstTheBackground(t *testing.T) { + t.Parallel() + + for _, th := range []theme.Theme{theme.Dark(), theme.Light()} { + got := contrastRatio(th.C.Divider, th.C.Background) + if got < 2.0 { + t.Errorf("%s theme: divider/background contrast %.2f, too low to read as a line", th.Name, got) + } + + // But it must stay subordinate to the dimmest text, or it competes + // with the content it is separating. + if contrastRatio(th.C.Divider, th.C.Background) >= contrastRatio(th.C.TextSubtle, th.C.Background) { + t.Errorf("%s theme: divider is as prominent as subtle text", th.Name) + } + } +} + +// Text has to clear the usual legibility bar against the surface it sits on. +func TestTextContrastIsLegible(t *testing.T) { + t.Parallel() + + for _, th := range []theme.Theme{theme.Dark(), theme.Light()} { + if got := contrastRatio(th.C.Text, th.C.Background); got < 7 { + t.Errorf("%s theme: text/background contrast %.2f, want at least 7", th.Name, got) + } + + if got := contrastRatio(th.C.TextSubtle, th.C.Background); got < 3 { + t.Errorf("%s theme: subtle text/background contrast %.2f, want at least 3", th.Name, got) + } + } +} From b9d69a0d8a0fa65d814ef4a4fe9f21f7687bf9ff Mon Sep 17 00:00:00 2001 From: Steven Crothers Date: Mon, 27 Jul 2026 10:04:41 -0400 Subject: [PATCH 02/11] =?UTF-8?q?tui:=20add=20the=20ui=20package=20?= =?UTF-8?q?=E2=80=94=20panels,=20status=20lines,=20meters?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The component layer: a chainable Style builder, Panel, StatusLine, Fields, and the fill meters. Every component returns exact dimensions so callers stack them without measuring. Three constraints are load-bearing rather than stylistic: Content styles carry a foreground and never a background. Lip Gloss terminates every styled run with a full SGR reset, so a container that painted its own background lost it at the first styled run inside it — which is what produced the color banding. The application surface is set once on the Bubble Tea View instead. ExpandTabs runs at every text leaf. lipgloss.Width("\t") is 0 but a terminal advances to the next tab stop, so an unexpanded tab paints wider than it measures and corrupts every row to its right. Overlay splices rows by hand because Lip Gloss cannot do it: Canvas.Compose draws each layer at full canvas bounds and Layer.Draw ignores its own X/Y, so composing a pane over a frame erases the frame. StatusLine sheds segments from the right one at a time. Dropping the right group wholesale made the line lurch on resize — a filling segment would gain the whole group's width across a single column of terminal, taking the meter from too-small-to-draw to enormous. --- internal/tui/ui/CLAUDE.md | 33 ++ internal/tui/ui/README.md | 40 ++ internal/tui/ui/doc.go | 21 + internal/tui/ui/fields.go | 77 ++++ internal/tui/ui/panel.go | 179 ++++++++ internal/tui/ui/status.go | 304 ++++++++++++++ internal/tui/ui/style.go | 211 ++++++++++ internal/tui/ui/ui_test.go | 839 +++++++++++++++++++++++++++++++++++++ 8 files changed, 1704 insertions(+) create mode 100644 internal/tui/ui/CLAUDE.md create mode 100644 internal/tui/ui/README.md create mode 100644 internal/tui/ui/doc.go create mode 100644 internal/tui/ui/fields.go create mode 100644 internal/tui/ui/panel.go create mode 100644 internal/tui/ui/status.go create mode 100644 internal/tui/ui/style.go create mode 100644 internal/tui/ui/ui_test.go diff --git a/internal/tui/ui/CLAUDE.md b/internal/tui/ui/CLAUDE.md new file mode 100644 index 0000000..7cedff7 --- /dev/null +++ b/internal/tui/ui/CLAUDE.md @@ -0,0 +1,33 @@ +# internal/tui/ui — agent notes + +## No literals at call sites + +The point of this package is that padding comes from `theme.Space*` and colors from `theme.Tokens`. A `lipgloss.Color("#7aa2f7")` or a bare `Px(3)` anywhere in `shell` or `paint` defeats it. If a needed value is missing, add it to the scale in `theme` rather than inlining it here. + +## Components must return exact dimensions + +`Panel.Render` returns exactly `Height` lines of exactly `Width` cells, and `Bar.Render` exactly one line of `Width` cells. Callers stack them without measuring, so a component that returns a ragged block silently shifts everything below it. Tests assert this across a range of sizes including degenerate ones. + +The subtle case: `strings.Split("", "\n")` returns one empty element, not zero. A panel with no interior (`Height == 2`) must skip its body loop outright rather than trusting an empty `FitBlock` to produce no rows. + +## `ExpandTabs` is a correctness fix, not formatting + +`lipgloss.Width("\t")` is 0 but a terminal advances to the next tab stop. Leaving a tab in content makes the painted row wider than the measured one, which overflows the pane and corrupts every row to its right. Expansion happens in `Fit` and at every text-bearing leaf in `paint`. Do not "simplify" it away. + +## Status lines drop from the right, and never advertise empty fields + +`StatusLine` lays segments left to right and drops from the right when they will not fit, so segment *order is a priority ranking*. A segment with no value and no `Fill` is omitted entirely — a status bar showing fields it has no data for teaches the operator to stop reading it. + +Exactly one segment per line should set `Fill`; it receives whatever width is left after every fixed segment is placed, which is what makes the line reflow on resize. `MinWidth` must account for the segment's *whole* rendering — label and trailing text included — not just its bar, or the fill callback gets a width too small to use. A filling segment should also cap itself: handed the slack of a very wide terminal, an uncapped meter becomes a rule with a number marooned at the far end. + +`Right` segments are fitted **after** the left group and only survive if the left group fits beside them whole. Reserving the right group's width first lets a secondary field evict a primary one, which inverts the ranking — there is a test named for exactly this. + +`Meter` uses the same heavy-against-light stroke as everything else that shows fill. Do not make the boundary color-only: it is the channel that survives a monochrome terminal and does not depend on telling green from red. + +## `Overlay` exists because Lip Gloss cannot do this + +`Canvas.Compose` draws every layer at full canvas bounds and `Layer.Draw` ignores its own X/Y, so composing a pane over a frame erases the frame instead of sitting on it. Row-wise ANSI splicing is the working approach. `Overlay` also clips a too-wide block rather than widening the row, because an over-wide row wraps and shifts the whole frame. + +## This package is pure domain + +No `log/slog`, no `internal/telemetry`, no I/O — the pure-domain exemption in `.claude/rules/logging-telemetry.md` applies. It takes tokens and strings and returns strings. diff --git a/internal/tui/ui/README.md b/internal/tui/ui/README.md new file mode 100644 index 0000000..ef188a3 --- /dev/null +++ b/internal/tui/ui/README.md @@ -0,0 +1,40 @@ +# internal/tui/ui + +The shell's utility and component layer — the terminal equivalent of a utility-first CSS framework. + +## Why it exists + +Without a layer like this, every pane picks its own padding, its own border color, and its own idea of what "muted" means. The result is a collection of individually reasonable choices that does not look like one system. This package applies the three rules that make utility-first styling work: + +- **Values come from a scale, never a literal.** Padding is `theme.Space1`..`Space4`; colors are `theme.Tokens` fields. A pane that wants more padding picks the next step; it does not invent `3`. +- **Utilities compose.** `Style` is a chainable builder where each method sets exactly one property, so a pane's appearance reads as a sentence where it is used rather than hiding in a named style somewhere else. +- **Components are compositions of utilities, not escapes from them.** `Panel` and `Bar` are built from the same builder any caller uses. + +## What lives here + +| Symbol | Role | +|---|---| +| `Style` | The chainable utility builder: `Fg`, `Bg`, `P`/`Px`/`Py`, `W`/`H`/`MaxW`, `Bold`, `Italic`, `Underline`, `Align` | +| `Panel` | A titled, bordered surface. Returns exactly `Height` lines of exactly `Width` cells | +| `Bar` | A full-width single-line strip with left and right ends pushed to the edges | +| `Badge` | A small filled label for status pills | +| `StatusLine` | A full-width row of labelled segments, one of which absorbs the slack | +| `Panel` | A titled, bordered box — used for content panes and for the header and footer alike | +| `Meter` | An inline fill bar, heavy against light stroke | +| `GradientMeter` | A fill bar whose color runs across a ramp along its length | +| `Fields` | A label/value list with values aligned into a column | +| `Fit` / `FitBlock` | Force a line or block to exact cell dimensions, ANSI-aware | +| `Clip` / `ClipLeft` | Truncate a line from either end; clip left to keep a path's tail | +| `Overlay` | Splice a block on top of a frame, preserving what surrounds it | +| `ExpandTabs` | Replace tabs with spaces before anything measures or wraps | + +## Two cell-accuracy rules worth knowing + +**Every component covers every cell it claims.** An uncovered cell shows the terminal's own background and breaks the illusion of a full-screen application, so `Panel` and `Bar` pad out to their full size rather than returning ragged lines. + +**Tabs are expanded on the way in.** A tab measures as zero cells but a terminal advances to the next tab stop when it draws one, so unexpanded tabs paint wider than they measure — overflowing the pane and corrupting every row to the right. Producer content routinely contains tabs (Go source, diffs). + +## Related + +- `internal/tui/theme` — the token set and scales this package consumes. +- [`docs/first-party/frontends/tui.md`](../../../docs/first-party/frontends/tui.md) — the design system this implements. diff --git a/internal/tui/ui/doc.go b/internal/tui/ui/doc.go new file mode 100644 index 0000000..efed3f0 --- /dev/null +++ b/internal/tui/ui/doc.go @@ -0,0 +1,21 @@ +// Package ui is the shell's utility and component layer — the terminal +// equivalent of a utility-first CSS framework. +// +// It exists for the reason Tailwind exists: without it, every pane picks its +// own padding, its own border color, and its own idea of what "muted" means, +// and the interface drifts into a collection of individually reasonable +// choices that do not look like one system. The rules here are the same ones +// that make that approach work: +// +// - Values come from a scale, never from a literal. Padding is +// theme.Space1..Space4; colors are theme.Tokens fields. A call site that +// wants "a bit more padding" picks the next step, it does not invent 3. +// - Utilities compose. Style is a chainable builder where each method sets +// exactly one property, so a pane's appearance reads as a sentence at the +// point of use rather than hiding in a named style elsewhere. +// - Components are compositions of utilities, not escapes from them. +// Panel and Bar are built from the same Style builder any caller uses. +// +// Everything here is pure: it takes tokens and strings and returns strings. +// No terminal, no I/O, no global state. +package ui diff --git a/internal/tui/ui/fields.go b/internal/tui/ui/fields.go new file mode 100644 index 0000000..45c91ec --- /dev/null +++ b/internal/tui/ui/fields.go @@ -0,0 +1,77 @@ +package ui + +import ( + "image/color" + "strings" + + "charm.land/lipgloss/v2" + + "github.com/pluggableharness/agent/internal/tui/theme" +) + +// Field is one label/value pair in a vertical list. +type Field struct { + Label string + Value string + // Tone colors the value. Nil uses ordinary text. + Tone color.Color + // Wide renders the value on its own line beneath the label, for values too + // long to sit beside one in a narrow panel. + Wide bool +} + +// Fields renders a label/value list with the labels aligned into a column. +// +// This is the shape almost every side panel wants, and aligning the values into +// a common column is what makes such a panel scannable — the eye tracks one +// vertical edge instead of hunting for where each value starts. Fields with no +// value are dropped, on the same principle as a status segment: a panel should +// not advertise what it cannot fill. +func Fields(t theme.Theme, fields []Field, width int) string { + present := make([]Field, 0, len(fields)) + + for _, f := range fields { + if f.Value != "" { + present = append(present, f) + } + } + + if len(present) == 0 || width <= 0 { + return "" + } + + labelWidth := 0 + for _, f := range present { + if !f.Wide { + labelWidth = max(labelWidth, lipgloss.Width(f.Label)) + } + } + + // A label column wider than half the panel is not a column, it is a wall. + // Past that, everything wraps to its own line instead. + stacked := labelWidth+2 > width/2 + + lines := make([]string, 0, len(present)) + for _, f := range present { + lines = append(lines, renderField(t, f, width, labelWidth, stacked)) + } + + return strings.Join(lines, "\n") +} + +func renderField(t theme.Theme, f Field, width, labelWidth int, stacked bool) string { + tone := t.C.Text + if f.Tone != nil { + tone = f.Tone + } + + label := New().Fg(t.C.TextSubtle).Render(f.Label) + + if stacked || f.Wide { + return label + "\n" + New().Fg(tone).Render(Clip(f.Value, width)) + } + + pad := strings.Repeat(" ", max(labelWidth-lipgloss.Width(f.Label), 0)+2) + + return label + pad + New().Fg(tone).Render(Clip(f.Value, max(width-labelWidth-2, 1))) +} diff --git a/internal/tui/ui/panel.go b/internal/tui/ui/panel.go new file mode 100644 index 0000000..1d5139d --- /dev/null +++ b/internal/tui/ui/panel.go @@ -0,0 +1,179 @@ +package ui + +import ( + "image/color" + "strings" + + "charm.land/lipgloss/v2" + + "github.com/pluggableharness/agent/internal/tui/theme" +) + +const ( + // minPanelWidth is the narrowest a panel can be and still have an interior: + // two border columns plus one content column. + minPanelWidth = 3 + // panelChromeRows is the two rows a panel spends on its own border. + panelChromeRows = 2 +) + +// Panel is a titled, bordered surface. +// +// The title sits in the top border rather than on its own row, which buys back +// a line of content per pane and is what keeps a stack of small side panels +// affordable. A focused panel is distinguished by border color alone — moving +// the border weight instead would reflow the layout on every focus change. +type Panel struct { + Title string + Body string + // Caption is optional text embedded in the *bottom* border, against the + // right corner. + // + // It is the counterweight to Title. A panel often has two things to say + // about itself — what it is, and how it is currently configured — and + // running both along the top border makes one long string in which neither + // is findable. Splitting them across the diagonal gives each a corner, so + // the eye learns where to look for which. Like the title it costs no + // interior row. + Caption string + // Width and Height are the panel's outer dimensions, borders included. + Width int + Height int + // Focused draws the border in the active color. + Focused bool + // Accent overrides the title color. Nil uses the muted text token. + Accent color.Color + // CaptionAccent overrides the caption color. Nil uses the subtle text + // token, which is dimmer than the title's default: a caption is reference + // detail, and it should read as chrome rather than compete with the title. + CaptionAccent color.Color + // Border overrides the border language. The zero value uses rounded. + Border *lipgloss.Border +} + +// Render draws the panel and returns exactly Height lines of exactly Width +// cells, so a caller can place it without measuring. +func (p Panel) Render(t theme.Theme) string { + if p.Width < minPanelWidth || p.Height < 2 { + // Too small to frame: still cover every cell so the region is painted + // rather than left showing whatever was there before. + return FitBlock(p.Body, max(p.Width, 0), max(p.Height, 0), New().Fg(t.C.Text)) + } + + b := theme.BorderRounded + if p.Border != nil { + b = *p.Border + } + + edge := t.C.Border + if p.Focused { + edge = t.C.BorderActive + } + + // Borders and interior carry no background of their own: the application + // surface is set once on the Bubble Tea View, and a container that filled + // its own background would only stay filled until the first styled run + // inside it emitted its terminating reset. + borderStyle := New().Fg(edge) + inner := p.Width - 2 + bodyHeight := p.Height - panelChromeRows + + rows := make([]string, 0, p.Height) + rows = append(rows, p.top(t, b, borderStyle, inner)) + + bodyStyle := New().Fg(t.C.Text) + contentWidth := max(inner-2*theme.Space1, 0) + pad := strings.Repeat(" ", theme.Space1) + + // A two-row panel is all border and has no interior. Splitting an empty + // block would still yield one line, so the body is skipped outright rather + // than trusted to produce none. + if bodyHeight > 0 { + for line := range strings.SplitSeq(FitBlock(p.Body, contentWidth, bodyHeight, bodyStyle), "\n") { + rows = append(rows, + borderStyle.Render(b.Left)+pad+line+pad+borderStyle.Render(b.Right)) + } + } + + rows = append(rows, p.bottom(t, b, borderStyle, inner)) + + return strings.Join(rows, "\n") +} + +// bottom renders the closing border with the caption embedded against the +// right corner, mirroring how top embeds the title against the left. +func (p Panel) bottom(t theme.Theme, b lipgloss.Border, borderStyle Style, inner int) string { + label := p.captionLabel(inner) + if label == "" { + return borderStyle.Render(b.BottomLeft + strings.Repeat(b.Bottom, inner) + b.BottomRight) + } + + accent := t.C.TextSubtle + if p.CaptionAccent != nil { + accent = p.CaptionAccent + } + + rest := max(inner-1-lipgloss.Width(label), 0) + + return borderStyle.Render(b.BottomLeft+strings.Repeat(b.Bottom, rest)) + + New().Fg(accent).Render(label) + + borderStyle.Render(b.Bottom+b.BottomRight) +} + +// captionLabel is the caption as it appears in the bottom border, padded. +// +// Unlike the title it is never clipped. A truncated model name ("claude-op…") +// is worse than no model name, because the operator cannot tell which model it +// abbreviates — and unlike a title, the caption is not what identifies the +// panel, so losing it costs nothing. It renders whole or not at all, the same +// way a status segment with no room is dropped rather than shortened. +func (p Panel) captionLabel(inner int) string { + if p.Caption == "" { + return "" + } + + label := " " + p.Caption + " " + + // Leave at least one border cell to the left of the label, so a caption + // that only just fits still reads as sitting in a border rather than + // having replaced it. + if lipgloss.Width(label) > inner-2 { + return "" + } + + return label +} + +// top builds the top border with the title embedded in it. +func (p Panel) top(t theme.Theme, b lipgloss.Border, borderStyle Style, inner int) string { + if p.Title == "" || inner < 4 { + return borderStyle.Render(b.TopLeft + strings.Repeat(b.Top, inner) + b.TopRight) + } + + accent := t.C.TextMuted + if p.Accent != nil { + accent = p.Accent + } + + label := p.titleLabel(inner) + titleStyle := New().Fg(accent).Bold() + rest := max(inner-1-lipgloss.Width(label), 0) + + return borderStyle.Render(b.TopLeft+b.Top) + + titleStyle.Render(label) + + borderStyle.Render(strings.Repeat(b.Top, rest)+b.TopRight) +} + +// titleLabel is the title as it appears in the top border, padded and clipped. +func (p Panel) titleLabel(inner int) string { + if p.Title == "" || inner < 4 { + return "" + } + + return " " + Fit(p.Title, min(lipgloss.Width(p.Title), inner-4)) + " " +} + +// Badge is a small filled label used for status pills in bars. +func Badge(t theme.Theme, text string, fg color.Color) string { + return New().Fg(t.C.OnAccent).Bg(fg).Bold().Px(theme.Space1).Render(text) +} diff --git a/internal/tui/ui/status.go b/internal/tui/ui/status.go new file mode 100644 index 0000000..4254240 --- /dev/null +++ b/internal/tui/ui/status.go @@ -0,0 +1,304 @@ +package ui + +import ( + "image/color" + "math" + "strings" + + "charm.land/lipgloss/v2" + + "github.com/pluggableharness/agent/internal/tui/theme" +) + +// SegmentSeparator divides adjacent status segments. +// +// Two cells either side rather than one: a status line packs many short +// label/value pairs, and with tight separators the eye cannot find the field +// boundaries. The extra breathing room is what makes it scannable. +const SegmentSeparator = " │ " + +// Segment is one field in a status line. +// +// A segment is a label and a value rather than bare text, because a status bar +// that only shows values is unreadable the first time and a bar that spells +// everything out is too wide. The label is dim and the value is not, so the eye +// lands on what changed. +type Segment struct { + // Label names the field, e.g. "model". Optional. + Label string + // Value is the field's current reading. A segment with no value and no + // Fill is dropped: a status bar should not advertise fields it has no data + // for. + Value string + // Tone colors the value. Nil uses the theme's ordinary text. + Tone color.Color + // Fill, when set, renders the segment at whatever width is left over after + // every fixed segment is placed. Exactly one segment per line should set + // it; the first one wins. + Fill func(width int) string + // MinWidth is the least a filling segment may be squeezed to before the + // line gives up on it. + MinWidth int +} + +// empty reports whether this segment has nothing to show. +func (s Segment) empty() bool { return s.Value == "" && s.Fill == nil } + +// text renders a fixed segment. +func (s Segment) text(t theme.Theme) string { + tone := t.C.Text + if s.Tone != nil { + tone = s.Tone + } + + value := New().Fg(tone).Render(s.Value) + if s.Label == "" { + return value + } + + return New().Fg(t.C.TextSubtle).Render(s.Label+" ") + value +} + +// StatusLine is one row of segments spanning the full width. +// +// Segments are laid out left to right, separated by a divider, with one +// optional segment absorbing the slack so the line always spans its width and +// reflows as the terminal resizes. When the line cannot fit, segments are +// dropped from the right — the leftmost fields are the ones chosen to matter +// most, so they are the ones that survive. +type StatusLine struct { + // Segments are laid out from the left edge. + Segments []Segment + // Right are pinned to the right edge, so the line spans its full width + // instead of leaving a wide terminal packed to one side. They are dropped + // before the left group when space runs out — the left is the ranked side. + Right []Segment + Width int + // Flush drops the line's own one-cell inset, for a line rendered inside a + // container that already pads it. Without this a status line inside a panel + // sits one column right of everything else, since it adds its inset on top + // of the panel's padding. + Flush bool +} + +// Render draws the line as exactly Width cells. +func (l StatusLine) Render(t theme.Theme) string { + segments := make([]Segment, 0, len(l.Segments)) + + for _, s := range l.Segments { + if !s.empty() { + segments = append(segments, s) + } + } + + if l.Width <= 0 { + return "" + } + + sep := New().Fg(t.C.Divider).Render(SegmentSeparator) + sepWidth := lipgloss.Width(SegmentSeparator) + + // One cell of inset at each edge keeps text off the boundary, unless a + // container has already provided it. + pad := theme.Space1 + if l.Flush { + pad = theme.Space0 + } + + edge := strings.Repeat(" ", pad) + avail := l.Width - 2*pad + + // A line with nothing on the left still renders its right group: the two + // groups are independent, and an empty left is a legitimate state rather + // than a reason to discard the other half of the line. + if len(segments) == 0 { + right := renderGroup(t, presentSegments(l.Right), sep) + gap := max(avail-lipgloss.Width(right), 0) + + return New().Render(edge + strings.Repeat(" ", gap) + right + edge) + } + + fixed, flexIndex := renderFixed(t, segments) + + // The right group yields one segment at a time rather than all at once. + // + // Dropping it wholesale makes the line lurch on resize: a filling segment + // suddenly gains the entire right group's width, which can take it from + // too-small-to-draw to enormous across a single column of terminal width. + // Shedding the rightmost field first keeps each step small. + rightSegs := presentSegments(l.Right) + rendered, right := fitBothGroups(t, fixed, flexIndex, segments, rightSegs, sep, avail, sepWidth) + + left := strings.Join(rendered, sep) + gap := max(avail-lipgloss.Width(left)-lipgloss.Width(right), 0) + + // When a filling segment has taken every spare cell, the space between the + // two groups is exactly one separator wide — because that is what was + // reserved for it. Draw the separator there. Leaving it blank is what + // produced a conspicuous hole between the last left field and the first + // right one, with no divider to explain it. + // + // Without a filling segment the gap is genuine slack pushing the right + // group to the edge, and a divider stranded in the middle of it would only + // look lost. + if flexIndex >= 0 && right != "" && gap == sepWidth { + return New().Render(edge + left + sep + right + edge) + } + + return New().Render(edge + left + strings.Repeat(" ", gap) + right + edge) +} + +// presentSegments drops the segments with nothing to show. +func presentSegments(in []Segment) []Segment { + out := make([]Segment, 0, len(in)) + + for _, s := range in { + if !s.empty() { + out = append(out, s) + } + } + + return out +} + +// fitBothGroups finds the largest right group the left group still fits beside, +// shedding the rightmost field first. +func fitBothGroups(t theme.Theme, fixed []string, flexIndex int, segments, rightSegs []Segment, sep string, avail, sepWidth int) ([]string, string) { + for keep := len(rightSegs); keep > 0; keep-- { + right := renderGroup(t, rightSegs[:keep], sep) + + rendered := fit(fixed, flexIndex, segments, avail-lipgloss.Width(right)-sepWidth, sepWidth) + if len(rendered) == len(segments) { + return rendered, right + } + } + + return fit(fixed, flexIndex, segments, avail, sepWidth), "" +} + +// renderGroup renders a run of segments joined by the separator. +func renderGroup(t theme.Theme, segs []Segment, sep string) string { + parts := make([]string, 0, len(segs)) + + for _, s := range segs { + parts = append(parts, s.text(t)) + } + + return strings.Join(parts, sep) +} + +// renderFixed renders every non-filling segment and reports which segment, if +// any, absorbs the slack. +func renderFixed(t theme.Theme, segments []Segment) ([]string, int) { + out := make([]string, len(segments)) + flexIndex := -1 + + for i, s := range segments { + if s.Fill != nil && flexIndex < 0 { + flexIndex = i + + continue + } + + out[i] = s.text(t) + } + + return out, flexIndex +} + +// fit drops segments from the right until the line fits, then hands whatever +// space is left to the filling segment. +func fit(rendered []string, flexIndex int, segments []Segment, avail, sepWidth int) []string { + keep := len(rendered) + + width := func(n int) int { + total := 0 + for i := range n { + if i != flexIndex { + total += lipgloss.Width(rendered[i]) + } + } + + if n > 1 { + total += (n - 1) * sepWidth + } + + if flexIndex >= 0 && flexIndex < n { + total += segments[flexIndex].MinWidth + } + + return total + } + + for keep > 0 && width(keep) > avail { + keep-- + } + + out := rendered[:keep] + + if flexIndex >= 0 && flexIndex < keep { + slack := avail - width(keep) + segments[flexIndex].MinWidth + out[flexIndex] = segments[flexIndex].Fill(max(slack, 0)) + } + + return out +} + +// Meter renders an inline fill bar of exactly width cells. +// +// It uses the same heavy-against-light stroke the rest of the shell uses for +// fill, so the reading survives a monochrome terminal and does not depend on +// telling one color from another. Color reinforces the measurement; it never +// carries it alone. +func Meter(width int, fill float64, on, off color.Color) string { + if width <= 0 { + return "" + } + + fill = math.Min(math.Max(fill, 0), 1) + filled := min(int(math.Round(fill*float64(width))), width) + + return New().Fg(on).Render(strings.Repeat("━", filled)) + + New().Fg(off).Render(strings.Repeat("─", width-filled)) +} + +// GradientMeter renders a fill bar whose color runs across a ramp along its +// length, rather than recoloring the whole bar as the value changes. +// +// The difference matters: a bar that is uniformly amber tells you the current +// state, while a bar that runs green through amber to red shows you the whole +// scale and where on it you currently sit. The consumed run is drawn in full +// color and the remainder in a muted blend of the same gradient, so the +// boundary stays legible — and it is still a heavy stroke against a light one, +// which is what makes the reading survive a monochrome terminal. +func GradientMeter(t theme.Theme, ramp theme.Ramp, width int, fill float64) string { + if width <= 0 { + return "" + } + + fill = math.Min(math.Max(fill, 0), 1) + filled := min(int(math.Round(fill*float64(width))), width) + + var b strings.Builder + + for i := range width { + // A one-cell bar has no length to run a gradient along; sample the + // start rather than dividing by zero. + pos := 0.0 + if width > 1 { + pos = float64(i) / float64(width-1) + } + + c := ramp.At(t, pos) + + if i < filled { + b.WriteString(New().Fg(c).Render("━")) + + continue + } + + b.WriteString(New().Fg(t.Muted(c)).Render("─")) + } + + return b.String() +} diff --git a/internal/tui/ui/style.go b/internal/tui/ui/style.go new file mode 100644 index 0000000..a31167f --- /dev/null +++ b/internal/tui/ui/style.go @@ -0,0 +1,211 @@ +package ui + +import ( + "image/color" + "strings" + + "charm.land/lipgloss/v2" + "github.com/charmbracelet/x/ansi" +) + +// Style is a chainable utility builder. Each method sets exactly one property +// and returns a new Style, so appearance composes at the point of use: +// +// ui.New().Bg(t.C.BackgroundPanel).Fg(t.C.TextMuted).Px(theme.Space1).Render(s) +// +// Values are expected to come from the theme's token set and spacing scale +// rather than from literals — that constraint is the whole point. +type Style struct{ s lipgloss.Style } + +// New returns an empty utility style. +func New() Style { return Style{s: lipgloss.NewStyle()} } + +// From wraps an existing Lip Gloss style so painter-derived styles can be +// extended with utilities without being rebuilt. +func From(s lipgloss.Style) Style { return Style{s: s} } + +// Fg sets the foreground color. +func (u Style) Fg(c color.Color) Style { return Style{s: u.s.Foreground(c)} } + +// Bg sets the background color. +func (u Style) Bg(c color.Color) Style { return Style{s: u.s.Background(c)} } + +// P sets padding on all four sides. +func (u Style) P(n int) Style { return Style{s: u.s.Padding(n, n)} } + +// Px sets horizontal padding. +func (u Style) Px(n int) Style { return Style{s: u.s.PaddingLeft(n).PaddingRight(n)} } + +// Py sets vertical padding. +func (u Style) Py(n int) Style { return Style{s: u.s.PaddingTop(n).PaddingBottom(n)} } + +// W sets an exact width, padding or wrapping content to fit. +func (u Style) W(n int) Style { return Style{s: u.s.Width(n)} } + +// H sets an exact height, padding with blank lines to fit. +func (u Style) H(n int) Style { return Style{s: u.s.Height(n)} } + +// MaxW clips content to a width without padding it out to that width. +func (u Style) MaxW(n int) Style { return Style{s: u.s.MaxWidth(n)} } + +// Bold enables bold text. +func (u Style) Bold() Style { return Style{s: u.s.Bold(true)} } + +// Italic enables italic text. +func (u Style) Italic() Style { return Style{s: u.s.Italic(true)} } + +// Underline enables underlined text. +func (u Style) Underline() Style { return Style{s: u.s.Underline(true)} } + +// Align sets horizontal alignment within the style's width. +func (u Style) Align(p lipgloss.Position) Style { return Style{s: u.s.AlignHorizontal(p)} } + +// Render applies the style to a string. +func (u Style) Render(s string) string { return u.s.Render(s) } + +// Lip returns the underlying Lip Gloss style, for the rare call that needs a +// property this builder deliberately does not expose. +func (u Style) Lip() lipgloss.Style { return u.s } + +// Width reports the display width of a rendered string, counting grapheme +// widths and ignoring ANSI escapes. +func Width(s string) int { return lipgloss.Width(s) } + +// TabWidth is how many spaces a tab expands to. +const TabWidth = 4 + +// ExpandTabs replaces tabs with spaces. +// +// This is not cosmetic. Width measurement counts a tab as zero cells, but a +// terminal advances the cursor to the next tab stop when it draws one, so any +// content containing a tab paints wider than it measures — which overflows its +// pane and corrupts every row to its right. Producer content routinely contains +// tabs (Go source, diffs), so expansion happens on the way in, before anything +// measures or wraps. +func ExpandTabs(s string) string { + if !strings.ContainsRune(s, '\t') { + return s + } + + return strings.ReplaceAll(s, "\t", strings.Repeat(" ", TabWidth)) +} + +// Fit forces a single line to exactly width cells, truncating what overflows +// and padding what falls short. It is ANSI-aware, so styled content keeps its +// escapes intact. +func Fit(line string, width int) string { + if width <= 0 { + return "" + } + + line = ExpandTabs(line) + + w := ansi.StringWidth(line) + if w > width { + return ansi.Truncate(line, width, "") + } + + return line + strings.Repeat(" ", width-w) +} + +// Clip truncates a line to width without padding it out, keeping ANSI escapes +// intact. Use it where a shorter string is acceptable but a padded one is not. +func Clip(line string, width int) string { + if width <= 0 { + return "" + } + + line = ExpandTabs(line) + if ansi.StringWidth(line) <= width { + return line + } + + return ansi.Truncate(line, width, "") +} + +// ClipLeft truncates a line from the left, keeping its tail and marking the cut +// with an ellipsis. +// +// Paths and repository names carry their meaning at the end: given too little +// room, "…/aiagent/internal/tui" tells you where you are and +// "/home/steven/code/…" does not. Clip from whichever end preserves the part +// that identifies the thing. +func ClipLeft(line string, width int) string { + if width <= 0 { + return "" + } + + line = ExpandTabs(line) + + w := ansi.StringWidth(line) + if w <= width { + return line + } + + if width <= 1 { + return "…" + } + + return "…" + ansi.TruncateLeft(line, w-width+1, "") +} + +// FitBlock forces a multi-line block to exactly width by height cells, so a +// pane's interior always covers every cell it claims. Uncovered cells are what +// let the terminal's own background show through and break the illusion of a +// full-screen application. +func FitBlock(block string, width, height int, fill Style) string { + lines := strings.Split(block, "\n") + + out := make([]string, 0, height) + for i := range height { + line := "" + if i < len(lines) { + line = lines[i] + } + + out = append(out, fill.Render(Fit(line, width))) + } + + return strings.Join(out, "\n") +} + +// Overlay splices a block on top of a background frame at (x, y), preserving +// the frame around it. +// +// Lip Gloss's Canvas and Layer types cannot do this: Canvas.Compose draws every +// layer at the full canvas bounds and Layer.Draw ignores its own X and Y, so a +// later layer erases the frame beneath it instead of sitting on it. Splicing +// row by row with ANSI-aware truncation is what actually keeps the background +// visible. +func Overlay(frame, block string, x, y int) string { + frameRows := strings.Split(frame, "\n") + blockRows := strings.Split(block, "\n") + + frameWidth := lipgloss.Width(frame) + x = max(x, 0) + + // Clip rather than overflow. A block wider than the space left of x would + // push each spliced row past the terminal width, and an over-wide row wraps + // and shifts everything below it. + blockWidth := min(lipgloss.Width(block), max(frameWidth-x, 0)) + if blockWidth == 0 { + return frame + } + + for i, blockRow := range blockRows { + row := y + i + if row < 0 || row >= len(frameRows) { + continue + } + + base := frameRows[row] + + left := ansi.Truncate(base, x, "") + left += strings.Repeat(" ", max(x-ansi.StringWidth(left), 0)) + right := ansi.TruncateLeft(base, x+blockWidth, "") + + frameRows[row] = left + Fit(blockRow, blockWidth) + right + } + + return strings.Join(frameRows, "\n") +} diff --git a/internal/tui/ui/ui_test.go b/internal/tui/ui/ui_test.go new file mode 100644 index 0000000..a354f1e --- /dev/null +++ b/internal/tui/ui/ui_test.go @@ -0,0 +1,839 @@ +package ui_test + +import ( + "regexp" + "strings" + "testing" + + "charm.land/lipgloss/v2" + + "github.com/pluggableharness/agent/internal/tui/theme" + "github.com/pluggableharness/agent/internal/tui/ui" +) + +var ansiPattern = regexp.MustCompile(`\x1b\[[0-9;]*[a-zA-Z]`) + +func plain(s string) string { return ansiPattern.ReplaceAllString(s, "") } + +func TestFitPadsAndTruncatesToExactWidth(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + in string + width int + want string + }{ + {"pads short", "ab", 5, "ab "}, + {"exact", "abcde", 5, "abcde"}, + {"truncates long", "abcdefgh", 5, "abcde"}, + {"empty pads", "", 3, " "}, + {"zero width", "abc", 0, ""}, + {"negative width", "abc", -2, ""}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + if got := ui.Fit(tc.in, tc.width); got != tc.want { + t.Fatalf("Fit(%q, %d) = %q, want %q", tc.in, tc.width, got, tc.want) + } + }) + } +} + +// Fit must measure display width, not byte length, or styled content would be +// truncated by the length of its escape sequences. +func TestFitIsANSIAware(t *testing.T) { + t.Parallel() + + styled := lipgloss.NewStyle().Bold(true).Render("abc") + + got := ui.Fit(styled, 6) + if w := lipgloss.Width(got); w != 6 { + t.Fatalf("styled Fit width = %d, want 6", w) + } + + if !strings.Contains(plain(got), "abc") { + t.Fatalf("styled Fit lost its content: %q", plain(got)) + } +} + +// A tab measures as zero cells but a terminal advances to the next tab stop +// when drawing one, so an unexpanded tab paints wider than it measures. +func TestExpandTabs(t *testing.T) { + t.Parallel() + + if lipgloss.Width("\t") != 0 { + t.Skip("lipgloss now measures tabs; the expansion rationale needs revisiting") + } + + got := ui.ExpandTabs("a\tb") + if want := "a" + strings.Repeat(" ", ui.TabWidth) + "b"; got != want { + t.Fatalf("ExpandTabs = %q, want %q", got, want) + } + + // A string with no tab is returned unchanged. + if got := ui.ExpandTabs("plain"); got != "plain" { + t.Fatalf("ExpandTabs(%q) = %q", "plain", got) + } + + // Fit expands on the way through, so measured and painted widths agree. + if w := lipgloss.Width(ui.Fit("a\tb", 10)); w != 10 { + t.Fatalf("Fit of tabbed content = %d cells, want 10", w) + } +} + +func TestFitBlockCoversExactlyWidthByHeight(t *testing.T) { + t.Parallel() + + got := ui.FitBlock("one\ntwo", 6, 4, ui.New()) + lines := strings.Split(got, "\n") + + if len(lines) != 4 { + t.Fatalf("got %d lines, want 4", len(lines)) + } + + for i, l := range lines { + if w := lipgloss.Width(l); w != 6 { + t.Errorf("line %d width = %d, want 6: %q", i, w, l) + } + } +} + +func TestFitBlockTruncatesExcessLines(t *testing.T) { + t.Parallel() + + got := ui.FitBlock("a\nb\nc\nd", 3, 2, ui.New()) + if lines := strings.Split(got, "\n"); len(lines) != 2 { + t.Fatalf("got %d lines, want 2", len(lines)) + } +} + +func TestOverlayPreservesTheFrameAroundTheBlock(t *testing.T) { + t.Parallel() + + frame := strings.Join([]string{ + "aaaaaaaaaa", + "bbbbbbbbbb", + "cccccccccc", + }, "\n") + + got := ui.Overlay(frame, "XX\nYY", 4, 1) + lines := strings.Split(got, "\n") + + want := []string{"aaaaaaaaaa", "bbbbXXbbbb", "ccccYYcccc"} + for i, w := range want { + if plain(lines[i]) != w { + t.Errorf("line %d = %q, want %q", i, plain(lines[i]), w) + } + } +} + +// A block that would extend past the frame is clipped, never allowed to widen +// a row — an over-wide row wraps and shifts everything below it. +func TestOverlayClipsRatherThanOverflowing(t *testing.T) { + t.Parallel() + + frame := "aaaaaaaaaa" + + got := ui.Overlay(frame, "XXXXXXXX", 6, 0) + if w := lipgloss.Width(got); w != 10 { + t.Fatalf("overlaid row width = %d, want 10: %q", w, plain(got)) + } + + // Entirely off the right edge: the frame is returned untouched. + if got := ui.Overlay(frame, "XX", 20, 0); got != frame { + t.Fatalf("off-frame overlay changed the frame: %q", plain(got)) + } +} + +func TestOverlayIgnoresRowsOutsideTheFrame(t *testing.T) { + t.Parallel() + + frame := "aaaa\nbbbb" + + if got := ui.Overlay(frame, "XX", 0, 5); got != frame { + t.Fatalf("below-frame overlay changed the frame: %q", plain(got)) + } + + if got := ui.Overlay(frame, "XX", 0, -3); got != frame { + t.Fatalf("above-frame overlay changed the frame: %q", plain(got)) + } +} + +func TestPanelRendersExactOuterDimensions(t *testing.T) { + t.Parallel() + + th := theme.Dark() + + for _, size := range [][2]int{{20, 5}, {40, 3}, {12, 8}, {3, 2}} { + w, h := size[0], size[1] + + got := ui.Panel{Title: "title", Body: "body\nmore", Width: w, Height: h}.Render(th) + lines := strings.Split(got, "\n") + + if len(lines) != h { + t.Errorf("%dx%d: got %d lines, want %d", w, h, len(lines), h) + + continue + } + + for i, l := range lines { + if lw := lipgloss.Width(l); lw != w { + t.Errorf("%dx%d line %d: width %d: %q", w, h, i, lw, plain(l)) + } + } + } +} + +func TestPanelShowsItsTitleAndBody(t *testing.T) { + t.Parallel() + + got := plain(ui.Panel{Title: "git", Body: "branch: main", Width: 30, Height: 4}.Render(theme.Dark())) + + for _, want := range []string{"git", "branch: main"} { + if !strings.Contains(got, want) { + t.Errorf("panel missing %q:\n%s", want, got) + } + } +} + +// A focused panel must be visually distinct, and it must not change size doing +// it — a border weight change would reflow the layout on every focus move. +func TestPanelFocusChangesStyleNotGeometry(t *testing.T) { + t.Parallel() + + th := theme.Dark() + base := ui.Panel{Title: "t", Body: "b", Width: 20, Height: 4} + focused := base + focused.Focused = true + + unfocusedOut := base.Render(th) + focusedOut := focused.Render(th) + + if unfocusedOut == focusedOut { + t.Fatal("focused panel rendered identically to unfocused") + } + + if plain(unfocusedOut) != plain(focusedOut) { + t.Fatalf("focus changed panel geometry:\n%s\n---\n%s", plain(unfocusedOut), plain(focusedOut)) + } +} + +// The caption sits in the bottom border against the right corner, opposite the +// title, and neither displaces the other. +func TestPanelCaptionSitsBottomRight(t *testing.T) { + t.Parallel() + + got := plain(ui.Panel{ + Title: "Code", + Caption: "claude-opus-5", + Body: "prompt", + Width: 40, + Height: 3, + }.Render(theme.Dark())) + + lines := strings.Split(got, "\n") + + if !strings.Contains(lines[0], "Code") { + t.Errorf("title not in the top border: %q", lines[0]) + } + + last := lines[len(lines)-1] + if !strings.Contains(last, "claude-opus-5") { + t.Fatalf("caption not in the bottom border: %q", last) + } + + // Right-aligned means exactly one border cell trails the label. + if !strings.HasSuffix(last, "claude-opus-5 ─╯") { + t.Errorf("caption not against the right corner: %q", last) + } + + if strings.Contains(lines[0], "claude-opus-5") { + t.Errorf("caption leaked into the top border: %q", lines[0]) + } +} + +// A caption that cannot fit whole is dropped rather than clipped: an +// abbreviated model name names no model. +func TestPanelDropsCaptionRatherThanClipIt(t *testing.T) { + t.Parallel() + + th := theme.Dark() + + for w := 8; w <= 40; w++ { + got := plain(ui.Panel{Caption: "claude-opus-5", Body: "b", Width: w, Height: 3}.Render(th)) + + last := strings.Split(got, "\n")[2] + if strings.Contains(last, "claude-opus-5") { + continue + } + + // Whatever survives must be border, never a fragment of the caption. + if strings.ContainsAny(last, "abcdefghijklmnopqrstuvwxyz0123456789") { + t.Errorf("width %d: clipped caption fragment: %q", w, last) + } + } +} + +// Too small to frame, the panel still covers its cells rather than letting the +// terminal show through. +func TestPanelDegradesWhenTooSmallToFrame(t *testing.T) { + t.Parallel() + + got := ui.Panel{Title: "t", Body: "body", Width: 2, Height: 1}.Render(theme.Dark()) + if w := lipgloss.Width(got); w != 2 { + t.Fatalf("degenerate panel width = %d, want 2", w) + } +} +func TestBadgeRendersItsText(t *testing.T) { + t.Parallel() + + th := theme.Dark() + + got := ui.Badge(th, "ready", th.C.Success) + if !strings.Contains(plain(got), "ready") { + t.Fatalf("badge lost its text: %q", plain(got)) + } +} + +// The utility builder is the whole point of the package: each method sets one +// property and they compose. +func TestStyleUtilitiesCompose(t *testing.T) { + t.Parallel() + + th := theme.Dark() + + got := ui.New(). + Fg(th.C.Text). + Bg(th.C.BackgroundPanel). + Px(theme.Space2). + W(20). + Bold(). + Italic(). + Underline(). + Align(lipgloss.Center). + Render("hi") + + if w := lipgloss.Width(got); w != 20 { + t.Fatalf("composed width = %d, want 20", w) + } + + if !strings.Contains(plain(got), "hi") { + t.Fatalf("composed style lost its content: %q", plain(got)) + } +} + +func TestStylePaddingHelpers(t *testing.T) { + t.Parallel() + + if got := plain(ui.New().P(theme.Space1).Render("x")); !strings.Contains(got, " x ") { + t.Errorf("P did not pad horizontally: %q", got) + } + + if got := plain(ui.New().Py(theme.Space1).Render("x")); !strings.Contains(got, "\n") { + t.Errorf("Py did not pad vertically: %q", got) + } + + if got := ui.New().H(3).Render("x"); lipgloss.Height(got) != 3 { + t.Errorf("H did not set height: %d", lipgloss.Height(got)) + } + + if got := ui.New().MaxW(3).Render("abcdef"); lipgloss.Width(got) > 3 { + t.Errorf("MaxW did not clip: %q", got) + } +} + +func TestFromAndLipRoundTrip(t *testing.T) { + t.Parallel() + + base := lipgloss.NewStyle().Bold(true) + + if got := ui.From(base).Render("x"); got != base.Render("x") { + t.Fatalf("From changed rendering: %q vs %q", got, base.Render("x")) + } + + if !ui.From(base).Lip().GetBold() { + t.Fatal("Lip did not return the wrapped style") + } +} + +func TestWidthMatchesLipgloss(t *testing.T) { + t.Parallel() + + if ui.Width("abc") != lipgloss.Width("abc") { + t.Fatal("ui.Width disagrees with lipgloss.Width") + } +} + +func TestStatusLineFillsItsWidth(t *testing.T) { + t.Parallel() + + th := theme.Dark() + + for _, w := range []int{20, 40, 80, 120} { + got := ui.StatusLine{ + Width: w, + Segments: []ui.Segment{ + {Label: "model", Value: "opus"}, + {Label: "cost", Value: "$0.42"}, + }, + }.Render(th) + + if lipgloss.Width(got) != w { + t.Errorf("width %d: line rendered %d cells", w, lipgloss.Width(got)) + } + } +} + +// Segments with nothing to show are omitted: a status bar should not advertise +// fields it has no data for. +func TestStatusLineDropsEmptySegments(t *testing.T) { + t.Parallel() + + got := plain(ui.StatusLine{ + Width: 60, + Segments: []ui.Segment{ + {Label: "model", Value: "opus"}, + {Label: "branch", Value: ""}, + {Label: "cost", Value: "$1"}, + }, + }.Render(theme.Dark())) + + if strings.Contains(got, "branch") { + t.Fatalf("empty segment was rendered: %q", got) + } + + for _, want := range []string{"model", "opus", "cost", "$1"} { + if !strings.Contains(got, want) { + t.Errorf("missing %q: %q", want, got) + } + } +} + +// A line that will not fit drops from the right, so the leftmost fields — the +// ones ranked most important — survive a narrow terminal. +func TestStatusLineDropsFromTheRight(t *testing.T) { + t.Parallel() + + line := ui.StatusLine{ + Width: 26, + Segments: []ui.Segment{ + {Label: "model", Value: "claude-opus-5"}, + {Label: "cost", Value: "$0.42"}, + {Label: "elapsed", Value: "22m00s"}, + }, + } + + got := plain(line.Render(theme.Dark())) + + if !strings.Contains(got, "claude-opus-5") { + t.Errorf("leftmost segment was dropped: %q", got) + } + + if strings.Contains(got, "22m00s") { + t.Errorf("rightmost segment survived a too-narrow line: %q", got) + } + + if lipgloss.Width(line.Render(theme.Dark())) != 26 { + t.Error("truncated line does not fill its width") + } +} + +// The filling segment absorbs whatever is left, so the line reflows with the +// terminal rather than leaving a ragged gap. +func TestStatusLineFillSegmentAbsorbsSlack(t *testing.T) { + t.Parallel() + + widths := map[int]int{} + + for _, w := range []int{60, 90, 120} { + ui.StatusLine{ + Width: w, + Segments: []ui.Segment{ + {Label: "model", Value: "opus"}, + { + MinWidth: 10, + Fill: func(width int) string { + widths[w] = width + + return strings.Repeat("=", width) + }, + }, + }, + }.Render(theme.Dark()) + } + + if widths[60] >= widths[90] || widths[90] >= widths[120] { + t.Fatalf("fill segment did not grow with the line: %v", widths) + } +} + +func TestStatusLineHandlesDegenerateInput(t *testing.T) { + t.Parallel() + + th := theme.Dark() + + if got := (ui.StatusLine{Width: 0}).Render(th); got != "" { + t.Errorf("zero width = %q, want empty", got) + } + + if got := (ui.StatusLine{Width: 10}).Render(th); lipgloss.Width(got) != 10 { + t.Errorf("no segments: width %d, want 10", lipgloss.Width(got)) + } +} + +func TestMeterFillsProportionally(t *testing.T) { + t.Parallel() + + th := theme.Dark() + + prev := -1 + + for i := range 11 { + got := plain(ui.Meter(20, float64(i)/10, th.C.Success, th.C.Border)) + + if lipgloss.Width(got) != 20 { + t.Fatalf("meter width = %d, want 20", lipgloss.Width(got)) + } + + filled := strings.Count(got, "━") + if filled < prev { + t.Errorf("fill %d0%%: %d cells, fewer than previous %d", i, filled, prev) + } + + prev = filled + } + + if strings.Count(plain(ui.Meter(20, 0, th.C.Success, th.C.Border)), "━") != 0 { + t.Error("an empty meter drew filled cells") + } + + if strings.Count(plain(ui.Meter(20, 1, th.C.Success, th.C.Border)), "━") != 20 { + t.Error("a full meter did not fill every cell") + } +} + +func TestMeterClampsAndHandlesZeroWidth(t *testing.T) { + t.Parallel() + + th := theme.Dark() + + if got := ui.Meter(0, 0.5, th.C.Success, th.C.Border); got != "" { + t.Errorf("zero-width meter = %q", got) + } + + for _, fill := range []float64{-5, 9} { + if w := lipgloss.Width(ui.Meter(10, fill, th.C.Success, th.C.Border)); w != 10 { + t.Errorf("fill %v: width %d, want 10", fill, w) + } + } +} + +// A wide terminal must not leave the bar packed to one side. +func TestStatusLineSpansWithARightGroup(t *testing.T) { + t.Parallel() + + line := ui.StatusLine{ + Width: 80, + Segments: []ui.Segment{{Label: "model", Value: "opus"}}, + Right: []ui.Segment{{Label: "cache", Value: "89%"}}, + } + + got := plain(line.Render(theme.Dark())) + + if lipgloss.Width(got) != 80 { + t.Fatalf("line width = %d, want 80", lipgloss.Width(got)) + } + + if !strings.HasPrefix(got, " model") { + t.Errorf("left group not at the left edge: %q", got) + } + + if !strings.HasSuffix(got, "89% ") { + t.Errorf("right group not at the right edge: %q", got) + } +} + +// The right group yields first when space runs out: the left is the ranked side. +func TestStatusLineDropsTheRightGroupWhenCrowded(t *testing.T) { + t.Parallel() + + got := plain(ui.StatusLine{ + Width: 30, + Segments: []ui.Segment{{Label: "model", Value: "claude-opus-5"}}, + Right: []ui.Segment{{Label: "elapsed", Value: "22m00s"}}, + }.Render(theme.Dark())) + + if strings.Contains(got, "22m00s") { + t.Errorf("right group survived a crowded line: %q", got) + } + + if !strings.Contains(got, "claude-opus-5") { + t.Errorf("left group was dropped instead: %q", got) + } +} + +// Empty right-hand segments are omitted like any other. +func TestStatusLineRightGroupDropsEmpties(t *testing.T) { + t.Parallel() + + got := plain(ui.StatusLine{ + Width: 60, + Segments: []ui.Segment{{Label: "repo", Value: "agent"}}, + Right: []ui.Segment{{Label: "pr", Value: ""}}, + }.Render(theme.Dark())) + + if strings.Contains(got, "pr") { + t.Errorf("empty right segment was rendered: %q", got) + } +} + +func TestFieldsAlignsValuesIntoAColumn(t *testing.T) { + t.Parallel() + + got := plain(ui.Fields(theme.Dark(), []ui.Field{ + {Label: "pr", Value: "#11"}, + {Label: "branch", Value: "main"}, + }, 40)) + + lines := strings.Split(got, "\n") + if len(lines) != 2 { + t.Fatalf("got %d lines, want 2", len(lines)) + } + + // Values start at the same column regardless of label length. + if strings.Index(lines[0], "#11") != strings.Index(lines[1], "main") { + t.Fatalf("values not aligned:\n%s", got) + } +} + +// Fields with no value are dropped, on the same principle as status segments. +func TestFieldsDropsEmptyValues(t *testing.T) { + t.Parallel() + + got := plain(ui.Fields(theme.Dark(), []ui.Field{ + {Label: "repo", Value: "agent"}, + {Label: "pr", Value: ""}, + }, 40)) + + if strings.Contains(got, "pr") { + t.Errorf("empty field rendered: %q", got) + } + + if ui.Fields(theme.Dark(), nil, 40) != "" { + t.Error("empty field list produced output") + } +} + +// A wide value takes its own line rather than being squeezed beside a label. +func TestFieldsWideValuesStack(t *testing.T) { + t.Parallel() + + got := plain(ui.Fields(theme.Dark(), []ui.Field{ + {Label: "dir", Value: "~/code/aiagent", Wide: true}, + }, 40)) + + if len(strings.Split(got, "\n")) != 2 { + t.Fatalf("wide field did not stack:\n%s", got) + } +} + +// A label column wider than half the panel is a wall, not a column, so +// everything stacks instead. +func TestFieldsStackWhenLabelsCrowdTheWidth(t *testing.T) { + t.Parallel() + + got := plain(ui.Fields(theme.Dark(), []ui.Field{ + {Label: "averylonglabel", Value: "x"}, + }, 16)) + + if len(strings.Split(got, "\n")) != 2 { + t.Fatalf("expected stacking on a narrow panel:\n%s", got) + } +} + +func TestFieldsClipsRatherThanOverflowing(t *testing.T) { + t.Parallel() + + got := plain(ui.Fields(theme.Dark(), []ui.Field{ + {Label: "repo", Value: strings.Repeat("x", 200)}, + }, 30)) + + for _, line := range strings.Split(got, "\n") { + if lipgloss.Width(line) > 30 { + t.Errorf("line overflowed: %d cells", lipgloss.Width(line)) + } + } +} + +// The gradient runs across the bar's length rather than recoloring the whole +// bar as the value changes: the point is to show the scale, not just the state. +func TestGradientMeterVariesAlongItsLength(t *testing.T) { + t.Parallel() + + th := theme.Dark() + + got := ui.GradientMeter(th, theme.DefaultGaugeRamp, 24, 1) + if lipgloss.Width(got) != 24 { + t.Fatalf("meter width = %d, want 24", lipgloss.Width(got)) + } + + // Every cell is filled, so any color difference is the gradient itself. + colors := regexp.MustCompile(`38;2;(\d+);(\d+);(\d+)`).FindAllStringSubmatch(got, -1) + if len(colors) < 24 { + t.Fatalf("got %d colored cells, want 24", len(colors)) + } + + if colors[0][0] == colors[len(colors)-1][0] { + t.Error("first and last cell share a color; the bar is not a gradient") + } +} + +func TestGradientMeterFillsProportionally(t *testing.T) { + t.Parallel() + + th := theme.Dark() + + prev := -1 + + for i := range 11 { + got := plain(ui.GradientMeter(th, theme.DefaultGaugeRamp, 20, float64(i)/10)) + + if lipgloss.Width(got) != 20 { + t.Fatalf("width = %d, want 20", lipgloss.Width(got)) + } + + filled := strings.Count(got, "━") + if filled < prev { + t.Errorf("fill %d0%%: %d cells, fewer than previous %d", i, filled, prev) + } + + prev = filled + } +} + +func TestGradientMeterHandlesDegenerateWidths(t *testing.T) { + t.Parallel() + + th := theme.Dark() + + if got := ui.GradientMeter(th, theme.DefaultGaugeRamp, 0, 0.5); got != "" { + t.Errorf("zero width = %q", got) + } + + // A one-cell bar has no length to run a gradient along and must not divide + // by zero. + if w := lipgloss.Width(ui.GradientMeter(th, theme.DefaultGaugeRamp, 1, 0.5)); w != 1 { + t.Errorf("one-cell meter width = %d", w) + } +} + +// A path keeps its tail, which is the part that identifies it. +func TestClipLeftKeepsTheTail(t *testing.T) { + t.Parallel() + + tests := []struct { + in string + width int + want string + }{ + {"/home/steven/code/aiagent", 12, "…ode/aiagent"}, + {"short", 20, "short"}, + {"exactfit", 8, "exactfit"}, + {"abc", 1, "…"}, + {"abc", 0, ""}, + } + + for _, tc := range tests { + got := ui.ClipLeft(tc.in, tc.width) + if got != tc.want { + t.Errorf("ClipLeft(%q, %d) = %q, want %q", tc.in, tc.width, got, tc.want) + } + + if tc.width > 0 && lipgloss.Width(got) > tc.width { + t.Errorf("ClipLeft(%q, %d) overflowed: %d cells", tc.in, tc.width, lipgloss.Width(got)) + } + } +} + +// A line with an empty left group still renders its right group: the two are +// independent, and an empty left is a legitimate state. +func TestStatusLineRendersRightWithNoLeftSegments(t *testing.T) { + t.Parallel() + + got := plain(ui.StatusLine{ + Width: 40, + Right: []ui.Segment{{Label: "state", Value: "ready"}}, + }.Render(theme.Dark())) + + if !strings.Contains(got, "ready") { + t.Fatalf("right group discarded with an empty left: %q", got) + } + + if lipgloss.Width(got) != 40 { + t.Errorf("width = %d, want 40", lipgloss.Width(got)) + } +} + +// When a filling segment has taken every spare cell, the space between the +// groups is exactly one separator wide — so draw the separator rather than +// leaving a conspicuous hole. +func TestStatusLineDrawsTheSeparatorBesideAFillSegment(t *testing.T) { + t.Parallel() + + got := plain(ui.StatusLine{ + Width: 60, + Segments: []ui.Segment{{ + MinWidth: 10, + Fill: func(w int) string { return strings.Repeat("=", w) }, + }}, + Right: []ui.Segment{{Label: "cost", Value: "$1"}}, + }.Render(theme.Dark())) + + if !strings.Contains(got, "="+strings.TrimRight(ui.SegmentSeparator, " ")) { + t.Fatalf("no separator between the fill and the right group: %q", got) + } +} + +// The right group sheds one field at a time. Dropping it wholesale makes a +// filling segment lurch by the entire group's width on a single column of +// resize. +func TestStatusLineShedsRightSegmentsOneAtATime(t *testing.T) { + t.Parallel() + + right := []ui.Segment{ + {Label: "cache", Value: "89%"}, + {Label: "cost", Value: "$0.42"}, + {Label: "elapsed", Value: "22m00s"}, + } + + seen := map[int]bool{} + + for w := 40; w <= 120; w++ { + got := plain(ui.StatusLine{ + Width: w, + Segments: []ui.Segment{{MinWidth: 12, Fill: func(n int) string { return strings.Repeat("=", n) }}}, + Right: right, + }.Render(theme.Dark())) + + kept := 0 + for _, s := range []string{"cache", "cost", "elapsed"} { + if strings.Contains(got, s) { + kept++ + } + } + + seen[kept] = true + } + + // Every intermediate count should occur somewhere in the range; an + // all-or-nothing group would only ever show 0 or 3. + for _, want := range []int{1, 2} { + if !seen[want] { + t.Errorf("right group never rendered exactly %d segments; it is dropping wholesale", want) + } + } +} From a87fb0981db5b1eefbe722c19372172495bb4929 Mon Sep 17 00:00:00 2001 From: Steven Crothers Date: Mon, 27 Jul 2026 10:04:41 -0400 Subject: [PATCH 03/11] =?UTF-8?q?tui:=20add=20the=20region=20package=20?= =?UTF-8?q?=E2=80=94=20placement=20store?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Holds what each producer contributed to each of the seven regions, and resolves the ordering the frontend protocol specifies: ranked first, then priority, then arrival sequence. Backed by a fixed-length array rather than a map so iteration order is positional and cannot vary run to run — the same determinism rule the kernel's event path follows. --- internal/tui/region/CLAUDE.md | 21 +++ internal/tui/region/README.md | 31 ++++ internal/tui/region/doc.go | 21 +++ internal/tui/region/region.go | 218 +++++++++++++++++++++++ internal/tui/region/region_test.go | 266 +++++++++++++++++++++++++++++ 5 files changed, 557 insertions(+) create mode 100644 internal/tui/region/CLAUDE.md create mode 100644 internal/tui/region/README.md create mode 100644 internal/tui/region/doc.go create mode 100644 internal/tui/region/region.go create mode 100644 internal/tui/region/region_test.go diff --git a/internal/tui/region/CLAUDE.md b/internal/tui/region/CLAUDE.md new file mode 100644 index 0000000..35e0bf5 --- /dev/null +++ b/internal/tui/region/CLAUDE.md @@ -0,0 +1,21 @@ +# internal/tui/region — agent notes + +## Never iterate a map to produce paint order + +Regions are held in a fixed-length array indexed by the enum, and `Contents` sorts a copied slice. Introducing a `map[Region][]Placement` and ranging over it would reintroduce exactly the nondeterminism `.claude/rules/determinism.md` forbids, and the failure mode is a frame that reorders between runs rather than a test failure. + +## `replace` is producer-scoped, not region-scoped + +`Place` with `replace: true` deletes only that producer's prior entries. A change that clears the whole region would break widget coexistence — two widgets sharing the sidebar would evict each other. There is a test named for this; if it fails, the semantics regressed. + +## Unset priority is not zero + +`Placement.Ranked` carries whether `PlacedContent.priority` was present. A zero priority is a *ranked* placement that sorts first; an absent priority sorts last. Collapsing these into `int32(0)` silently reorders every unranked producer to the front. + +## `Store` is deliberately not safe for concurrent use + +The shell owns one per session and mutates it only from the Bubble Tea update goroutine. That single-goroutine ownership is what makes the absence of locking correct. If something ever needs to write from another goroutine, route it through a `tea.Msg` rather than adding a mutex here. + +## This package is pure domain + +No `log/slog`, no `internal/telemetry`, no I/O — the pure-domain exemption in `.claude/rules/logging-telemetry.md` applies. It is 100%-covered; keep it there. diff --git a/internal/tui/region/README.md b/internal/tui/region/README.md new file mode 100644 index 0000000..345b43f --- /dev/null +++ b/internal/tui/region/README.md @@ -0,0 +1,31 @@ +# internal/tui/region + +The reference TUI shell's content store: the per-region set of placements every producer has contributed, and the ordering rule that decides what paints first. + +## What lives here + +- `Store` — one session's placed content, indexed by region. +- `Placement` — one producer's contribution: its tree, its priority (and whether priority was set at all), and the kernel sequence it arrived with. +- `Stream` — an in-progress streamed text block, correlated by `target_id`. +- `Normalize` — folds `REGION_UNSPECIFIED` and any unrecognized region value onto `REGION_MAIN_CHAT`. + +## The model + +A region is **not** a single-writer slot. The protocol's default is coexistence: several producers may target one region, and the frontend arbitrates by priority rather than evicting. `PlacedContent.replace` therefore supersedes only the placements of the producer that sent it, never another producer's. + +Ordering is `(ranked, priority, sequence)` ascending: + +- A placement with priority set sorts ahead of every placement without one. +- Unset priority means "declaration order", which is `sequence` order. +- `sequence` is the only tiebreak. + +## Determinism + +Wall clock is never an input to ordering, and regions live in a fixed-length array rather than a map, so paint order cannot vary with Go's map iteration. Both are required by [`.claude/rules/determinism.md`](../../../.claude/rules/determinism.md). The consequence that matters: two shells replaying one session compose identical frames. + +Rendered output is derived state — recomputed from this store, never persisted, never cached to disk. + +## Related + +- [`docs/specifications/frontend/render-tree.md`](../../../docs/specifications/frontend/render-tree.md) — the `Region`/`PlacedContent` vocabulary. +- [`docs/first-party/frontends/tui.md`](../../../docs/first-party/frontends/tui.md) — how the shell lays these regions out. diff --git a/internal/tui/region/doc.go b/internal/tui/region/doc.go new file mode 100644 index 0000000..e54a572 --- /dev/null +++ b/internal/tui/region/doc.go @@ -0,0 +1,21 @@ +// Package region owns the reference TUI shell's content store: the per-region +// set of placements contributed by every producer, and the ordering rule that +// decides what paints first. +// +// The store models the protocol's coexistence default +// (docs/specifications/frontend/render-tree.md): a region is not a +// single-writer slot, so several producers may target one region and the +// frontend arbitrates by priority rather than evicting. PlacedContent.replace +// supersedes only the placements of the producer that sent it, never another +// producer's. +// +// Ordering is (ranked, priority, sequence) ascending, with unset priority +// sorting after every ranked entry and sequence as the sole tiebreak. Wall +// clock is never an input and regions are held in a fixed-length array rather +// than a map, so paint order cannot vary with Go's map iteration — both +// required by .claude/rules/determinism.md. The practical consequence is that +// two shells replaying one session compose identical frames. +// +// Nothing in this package performs I/O or touches a terminal, so the whole +// ordering contract is testable headlessly. +package region diff --git a/internal/tui/region/region.go b/internal/tui/region/region.go new file mode 100644 index 0000000..0121167 --- /dev/null +++ b/internal/tui/region/region.go @@ -0,0 +1,218 @@ +package region + +import ( + "math" + "sort" + + renderv1 "github.com/pluggableharness/agent/pkg/render/proto/v1" +) + +// count is the number of values in the Region enum, including +// REGION_UNSPECIFIED at zero. The store indexes a fixed-length array by enum +// value so paint order never depends on map iteration. +const count = 7 + +// Producer identifies the plugin that contributed a placement. Identity is +// always server-derived from the authenticated connection — a plugin cannot +// declare an identity other than its own — so the shell treats this as +// trustworthy for the purpose of scoping replace semantics. +type Producer struct { + Category string + Name string +} + +// Placement is one producer's contribution to one region. +type Placement struct { + // Producer is who contributed this content. + Producer Producer + // Priority is the producer's ordering hint. Ranked reports whether it was + // set at all: an unset priority is not zero, it sorts after every ranked + // placement. + Priority int32 + Ranked bool + // Sequence is the kernel's event sequence, the sole ordering tiebreak. + Sequence uint64 + // Tree is the content to paint. + Tree *renderv1.RenderTree +} + +// Stream is an in-progress streamed text block, correlated by the target ID +// the kernel's stream_delta events carry. Streams paint at the tail of +// REGION_MAIN_CHAT, after every settled placement, because they are by +// definition the live edge of the transcript. +type Stream struct { + TargetID string + Text string + // first is the arrival index, used to keep multiple concurrent streams in + // a stable order without consulting the clock. + first uint64 +} + +// Store holds all placed content for a single session. +// +// A Store is not safe for concurrent use. The shell owns one per attached +// session and mutates it only from the Bubble Tea update goroutine, which is +// what makes the absence of locking correct rather than merely convenient. +type Store struct { + regions [count][]Placement + streams []Stream + arrival uint64 +} + +// NewStore returns an empty Store. +func NewStore() *Store { return &Store{} } + +// inRange reports whether r is a region value this build knows how to index. +// A value outside the range is one added to the enum after this shell shipped; +// callers fold it into REGION_MAIN_CHAT rather than dropping the content. +func inRange(r renderv1.Region) bool { + return r >= 0 && int(r) < count +} + +// Normalize maps a wire region value onto the region this shell will actually +// use for it. REGION_UNSPECIFIED means "the producer did not choose", which the +// protocol defines as REGION_MAIN_CHAT; an unrecognized value from a newer +// protocol build folds to the same place, since the alternative is silently +// dropping content the frontend is required to render gracefully. +func Normalize(r renderv1.Region) renderv1.Region { + if !inRange(r) || r == renderv1.Region_REGION_UNSPECIFIED { + return renderv1.Region_REGION_MAIN_CHAT + } + + return r +} + +// Place adds content to the store. When pc.Replace is set, the placement +// supersedes that producer's prior placements in the same region and leaves +// every other producer's untouched; otherwise it is appended alongside them. +// +// Place is a no-op for nil content, so a malformed event degrades to "nothing +// shown" rather than a panic in the paint path. +func (s *Store) Place(pc *renderv1.PlacedContent, p Producer, sequence uint64) { + if pc == nil || pc.GetContent() == nil { + return + } + + r := Normalize(pc.GetRegion()) + next := Placement{ + Producer: p, + Sequence: sequence, + Tree: pc.GetContent(), + } + + if pc.Priority != nil { + next.Priority = pc.GetPriority() + next.Ranked = true + } + + if pc.GetReplace() { + s.regions[r] = deleteProducer(s.regions[r], p) + } + + s.regions[r] = append(s.regions[r], next) +} + +// deleteProducer removes every placement contributed by p, preserving the +// relative order of the rest. +func deleteProducer(in []Placement, p Producer) []Placement { + out := in[:0] + + for _, pl := range in { + if pl.Producer != p { + out = append(out, pl) + } + } + + return out +} + +// Contents returns the placements for a region in paint order. The returned +// slice is a fresh copy, so a caller may hold or reorder it without disturbing +// the store. +func (s *Store) Contents(r renderv1.Region) []Placement { + if !inRange(r) { + return nil + } + + out := make([]Placement, len(s.regions[r])) + copy(out, s.regions[r]) + + sort.SliceStable(out, func(i, j int) bool { + li, lj := rank(out[i]), rank(out[j]) + if li != lj { + return li < lj + } + + return out[i].Sequence < out[j].Sequence + }) + + return out +} + +// rank projects a placement's priority onto a total order. An unset priority +// sorts after every ranked placement, which is what "unset = declaration +// order" means once ranked entries are allowed to jump the queue. +func rank(p Placement) int64 { + if !p.Ranked { + return math.MaxInt64 + } + + return int64(p.Priority) +} + +// Delta appends streamed text to the buffer for targetID, creating it on first +// sight. Consecutive deltas for one target accumulate into a single growing +// block rather than becoming separate lines. +func (s *Store) Delta(targetID, text string) { + for i := range s.streams { + if s.streams[i].TargetID == targetID { + s.streams[i].Text += text + + return + } + } + + s.arrival++ + s.streams = append(s.streams, Stream{TargetID: targetID, Text: text, first: s.arrival}) +} + +// ClearStream drops the buffer for targetID. The shell calls this when the +// finished render for a streamed block arrives, so the completed content +// replaces the live buffer instead of appearing twice. +func (s *Store) ClearStream(targetID string) { + out := s.streams[:0] + + for _, st := range s.streams { + if st.TargetID != targetID { + out = append(out, st) + } + } + + s.streams = out +} + +// ClearProducerStreams drops every live buffer, which is the coarse form of +// ClearStream used when a producer settles content and the shell cannot +// correlate it to a specific target ID. +func (s *Store) ClearProducerStreams() { s.streams = nil } + +// Streams returns the live streamed blocks in arrival order. +func (s *Store) Streams() []Stream { + out := make([]Stream, len(s.streams)) + copy(out, s.streams) + + sort.SliceStable(out, func(i, j int) bool { return out[i].first < out[j].first }) + + return out +} + +// Reset empties the store, used when a session is detached or re-backfilled so +// replayed history does not stack on top of what was already painted. +func (s *Store) Reset() { + for i := range s.regions { + s.regions[i] = nil + } + + s.streams = nil + s.arrival = 0 +} diff --git a/internal/tui/region/region_test.go b/internal/tui/region/region_test.go new file mode 100644 index 0000000..0dcd77b --- /dev/null +++ b/internal/tui/region/region_test.go @@ -0,0 +1,266 @@ +package region_test + +import ( + "testing" + + "github.com/pluggableharness/agent/internal/tui/region" + "github.com/pluggableharness/agent/pkg/render" + renderv1 "github.com/pluggableharness/agent/pkg/render/proto/v1" +) + +func placed(r renderv1.Region, text string, replace bool, priority *int32) *renderv1.PlacedContent { + return &renderv1.PlacedContent{ + Region: r, + Content: render.Tree(render.Text(text)), + Replace: replace, + Priority: priority, + } +} + +func texts(t *testing.T, in []region.Placement) []string { + t.Helper() + + out := make([]string, 0, len(in)) + for _, p := range in { + out = append(out, p.Tree.GetRoot().GetText().GetContent()) + } + + return out +} + +func equal(a, b []string) bool { + if len(a) != len(b) { + return false + } + + for i := range a { + if a[i] != b[i] { + return false + } + } + + return true +} + +func TestNormalize(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + in renderv1.Region + want renderv1.Region + }{ + {"unspecified folds to main chat", renderv1.Region_REGION_UNSPECIFIED, renderv1.Region_REGION_MAIN_CHAT}, + {"known region is preserved", renderv1.Region_REGION_SIDEBAR, renderv1.Region_REGION_SIDEBAR}, + {"future region folds to main chat", renderv1.Region(42), renderv1.Region_REGION_MAIN_CHAT}, + {"negative region folds to main chat", renderv1.Region(-1), renderv1.Region_REGION_MAIN_CHAT}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + if got := region.Normalize(tc.in); got != tc.want { + t.Fatalf("Normalize(%v) = %v, want %v", tc.in, got, tc.want) + } + }) + } +} + +func TestPlaceAppendsByDefault(t *testing.T) { + t.Parallel() + + s := region.NewStore() + p := region.Producer{Category: "tool", Name: "fs"} + + s.Place(placed(renderv1.Region_REGION_MAIN_CHAT, "first", false, nil), p, 1) + s.Place(placed(renderv1.Region_REGION_MAIN_CHAT, "second", false, nil), p, 2) + + got := texts(t, s.Contents(renderv1.Region_REGION_MAIN_CHAT)) + if want := []string{"first", "second"}; !equal(got, want) { + t.Fatalf("append semantics broken: got %v, want %v", got, want) + } +} + +// Replace is scoped to the producer that sent it. The protocol's default is +// coexistence, not exclusivity, so one producer replacing its own content must +// never evict another's from the same region. +func TestReplaceIsScopedToOneProducer(t *testing.T) { + t.Parallel() + + s := region.NewStore() + git := region.Producer{Category: "widget", Name: "git"} + ctx := region.Producer{Category: "widget", Name: "context"} + + s.Place(placed(renderv1.Region_REGION_SIDEBAR, "git v1", true, nil), git, 1) + s.Place(placed(renderv1.Region_REGION_SIDEBAR, "context", true, nil), ctx, 2) + s.Place(placed(renderv1.Region_REGION_SIDEBAR, "git v2", true, nil), git, 3) + + got := texts(t, s.Contents(renderv1.Region_REGION_SIDEBAR)) + if want := []string{"context", "git v2"}; !equal(got, want) { + t.Fatalf("replace evicted the wrong producer: got %v, want %v", got, want) + } +} + +// Unset priority sorts after every ranked placement, and sequence is the only +// tiebreak. Wall clock is never consulted. +func TestContentsOrdering(t *testing.T) { + t.Parallel() + + s := region.NewStore() + p := region.Producer{Category: "widget", Name: "w"} + + lo, hi := int32(1), int32(50) + + s.Place(placed(renderv1.Region_REGION_SIDEBAR, "unranked-early", false, nil), p, 1) + s.Place(placed(renderv1.Region_REGION_SIDEBAR, "ranked-high", false, &hi), p, 2) + s.Place(placed(renderv1.Region_REGION_SIDEBAR, "unranked-late", false, nil), p, 3) + s.Place(placed(renderv1.Region_REGION_SIDEBAR, "ranked-low", false, &lo), p, 4) + + got := texts(t, s.Contents(renderv1.Region_REGION_SIDEBAR)) + want := []string{"ranked-low", "ranked-high", "unranked-early", "unranked-late"} + + if !equal(got, want) { + t.Fatalf("ordering wrong\ngot: %v\nwant: %v", got, want) + } +} + +// A zero priority is a ranked placement, not an unset one — the distinction the +// optional field exists to carry. +func TestZeroPriorityIsRanked(t *testing.T) { + t.Parallel() + + s := region.NewStore() + p := region.Producer{Category: "widget", Name: "w"} + zero := int32(0) + + s.Place(placed(renderv1.Region_REGION_SIDEBAR, "unranked", false, nil), p, 1) + s.Place(placed(renderv1.Region_REGION_SIDEBAR, "ranked-zero", false, &zero), p, 2) + + got := texts(t, s.Contents(renderv1.Region_REGION_SIDEBAR)) + if want := []string{"ranked-zero", "unranked"}; !equal(got, want) { + t.Fatalf("zero priority treated as unset: got %v, want %v", got, want) + } +} + +func TestContentsIsDeterministic(t *testing.T) { + t.Parallel() + + s := region.NewStore() + + for i := range 20 { + p := region.Producer{Category: "widget", Name: string(rune('a' + i%5))} + s.Place(placed(renderv1.Region_REGION_MAIN_CHAT, "n", false, nil), p, uint64(i)) + } + + first := texts(t, s.Contents(renderv1.Region_REGION_MAIN_CHAT)) + + for range 25 { + if got := texts(t, s.Contents(renderv1.Region_REGION_MAIN_CHAT)); !equal(got, first) { + t.Fatal("Contents returned a different order across calls; paint order is not deterministic") + } + } +} + +func TestContentsReturnsACopy(t *testing.T) { + t.Parallel() + + s := region.NewStore() + p := region.Producer{Category: "tool", Name: "fs"} + + s.Place(placed(renderv1.Region_REGION_MAIN_CHAT, "a", false, nil), p, 1) + s.Place(placed(renderv1.Region_REGION_MAIN_CHAT, "b", false, nil), p, 2) + + got := s.Contents(renderv1.Region_REGION_MAIN_CHAT) + got[0], got[1] = got[1], got[0] + + after := texts(t, s.Contents(renderv1.Region_REGION_MAIN_CHAT)) + if want := []string{"a", "b"}; !equal(after, want) { + t.Fatalf("mutating the returned slice disturbed the store: got %v", after) + } +} + +func TestPlaceIgnoresNilContent(t *testing.T) { + t.Parallel() + + s := region.NewStore() + p := region.Producer{Category: "tool", Name: "fs"} + + s.Place(nil, p, 1) + s.Place(&renderv1.PlacedContent{Region: renderv1.Region_REGION_MAIN_CHAT}, p, 2) + + if got := s.Contents(renderv1.Region_REGION_MAIN_CHAT); len(got) != 0 { + t.Fatalf("nil content was stored: %d placements", len(got)) + } +} + +func TestContentsOfUnknownRegionIsEmpty(t *testing.T) { + t.Parallel() + + if got := region.NewStore().Contents(renderv1.Region(99)); got != nil { + t.Fatalf("expected nil for out-of-range region, got %v", got) + } +} + +func TestStreamsAccumulateByTargetID(t *testing.T) { + t.Parallel() + + s := region.NewStore() + + s.Delta("a", "hello ") + s.Delta("b", "other") + s.Delta("a", "world") + + got := s.Streams() + if len(got) != 2 { + t.Fatalf("got %d streams, want 2", len(got)) + } + + // Arrival order, not map order. + if got[0].TargetID != "a" || got[0].Text != "hello world" { + t.Fatalf("stream a = %+v, want accumulated text in arrival position 0", got[0]) + } + + if got[1].TargetID != "b" || got[1].Text != "other" { + t.Fatalf("stream b = %+v", got[1]) + } +} + +func TestClearStream(t *testing.T) { + t.Parallel() + + s := region.NewStore() + s.Delta("a", "x") + s.Delta("b", "y") + s.ClearStream("a") + + got := s.Streams() + if len(got) != 1 || got[0].TargetID != "b" { + t.Fatalf("ClearStream removed the wrong buffer: %+v", got) + } + + s.ClearProducerStreams() + + if len(s.Streams()) != 0 { + t.Fatal("ClearProducerStreams left buffers behind") + } +} + +func TestReset(t *testing.T) { + t.Parallel() + + s := region.NewStore() + p := region.Producer{Category: "tool", Name: "fs"} + + s.Place(placed(renderv1.Region_REGION_MAIN_CHAT, "a", false, nil), p, 1) + s.Place(placed(renderv1.Region_REGION_SIDEBAR, "b", false, nil), p, 2) + s.Delta("t", "streaming") + + s.Reset() + + if len(s.Contents(renderv1.Region_REGION_MAIN_CHAT)) != 0 || + len(s.Contents(renderv1.Region_REGION_SIDEBAR)) != 0 || + len(s.Streams()) != 0 { + t.Fatal("Reset left content behind; a re-backfill would stack on stale state") + } +} From 19aedb7a8265a3e64d8d0e635e88c97ff1a4e9a3 Mon Sep 17 00:00:00 2001 From: Steven Crothers Date: Mon, 27 Jul 2026 10:04:54 -0400 Subject: [PATCH 04/11] =?UTF-8?q?tui:=20add=20the=20paint=20package=20?= =?UTF-8?q?=E2=80=94=20RenderTree=20painter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Turns a protocol RenderTree into styled terminal rows, and walks the same tree to collect activation targets so a frontend can dispatch an ActionNode without a second traversal. Pure: it takes a tree and a width and returns strings. No I/O, no state, no clock — which is what lets the shell's frame tests assert against exact painted output. Unknown node kinds fall back to FallbackText rather than rendering nothing. A frontend built against an older protocol revision must degrade to showing something, not silently drop a producer's content. --- internal/tui/paint/CLAUDE.md | 23 +++ internal/tui/paint/README.md | 35 ++++ internal/tui/paint/doc.go | 18 ++ internal/tui/paint/paint.go | 330 +++++++++++++++++++++++++++++++ internal/tui/paint/paint_test.go | 214 ++++++++++++++++++++ internal/tui/paint/walk.go | 96 +++++++++ internal/tui/paint/walk_test.go | 156 +++++++++++++++ 7 files changed, 872 insertions(+) create mode 100644 internal/tui/paint/CLAUDE.md create mode 100644 internal/tui/paint/README.md create mode 100644 internal/tui/paint/doc.go create mode 100644 internal/tui/paint/paint.go create mode 100644 internal/tui/paint/paint_test.go create mode 100644 internal/tui/paint/walk.go create mode 100644 internal/tui/paint/walk_test.go diff --git a/internal/tui/paint/CLAUDE.md b/internal/tui/paint/CLAUDE.md new file mode 100644 index 0000000..f945565 --- /dev/null +++ b/internal/tui/paint/CLAUDE.md @@ -0,0 +1,23 @@ +# internal/tui/paint — agent notes + +## The `default:` branch in `Painter.Node` is load-bearing + +It handles a `RenderNode` variant added to the enum after this build shipped, and it delegates to `pkg/frontend.FallbackText`. The protocol states as a MUST that a frontend render such a node gracefully rather than erroring or dropping it. Do not replace this with a panic, an error return, or an empty string, and do not reimplement the traversal locally — `pkg/frontend` already owns it. + +## `Targets` must mirror the painter's traversal exactly + +`walk.go` and `paint.go` walk the same tree with the same path scheme (`parent + "." + index`, rooted at the caller-supplied path). If they diverge, the action cursor highlights one node and activates another — a bug that no compiler catches. Change them together, and keep `TargetsAt`/`TreeAt` root paths in sync. + +Collapsed children are deliberately excluded from `Targets`: a cursor must not move over content the operator cannot see. + +## `GroupNode` adds no chrome, on purpose + +The spec defines it as a transparent container. A test asserts that a grouped pair of nodes renders byte-identically to the same nodes rendered separately and joined. Adding a border or indent "for readability" breaks the node's stated meaning. + +## Assertions must strip ANSI + +Lip Gloss emits SGR escapes per character for some styles (underline, notably), so `strings.Contains(got, "text")` fails on styled output. The tests use a `plain()` helper; use it for any new assertion on rendered content. + +## This package is pure domain + +No `log/slog`, no `internal/telemetry`, no I/O — the pure-domain exemption in `.claude/rules/logging-telemetry.md` applies. diff --git a/internal/tui/paint/README.md b/internal/tui/paint/README.md new file mode 100644 index 0000000..0c94343 --- /dev/null +++ b/internal/tui/paint/README.md @@ -0,0 +1,35 @@ +# internal/tui/paint + +Renders a `RenderTree` into styled terminal text, and enumerates the keyboard-reachable elements inside one. + +## What lives here + +- `Painter` — one method per node type, dispatching off the `RenderNode` oneof. Pure: no terminal state, no I/O, no clock. +- `Opts` — the per-paint state the shell owns: width, which action is under the cursor, and which collapsible paths are toggled open. +- `Targets` / `TargetsAt` — the focusable elements in a tree, in the same order the painter emits them, so a cursor index and a rendered node always name the same element. + +## Node treatments + +| Node | Treatment | +|---|---| +| `TextNode` | Styled per `TextStyle`; unset resolves to the theme default | +| `CodeBlockNode` | Indented block with an optional language label | +| `DiffNode` | Dim hunk headers, `+`/`-` gutters, truncated rather than wrapped | +| `TableNode` | Column-aligned, truncated rather than wrapped | +| `LinkNode` | Label plus dimmed URL | +| `ListNode` | Bulleted or numbered, with hanging indent | +| `GroupNode` | Transparent — no border, indent, or label | +| `CollapsibleNode` | Disclosure marker honoring `collapsed_by_default` | +| `SubSessionNode` | A one-line pointer, never inlined | +| `ActionNode` | Button-styled, highlighted under the cursor | + +Diffs and tables truncate instead of wrapping because wrapping destroys the column alignment those node types exist to convey. + +## Why it is pure + +Every protocol obligation about rendering is testable without a terminal: graceful fallback for unrecognized node types, styles with no visual distinction still showing their text, and a bad node degrading rather than crashing the process. Keeping the painter free of terminal state is what lets the whole node vocabulary be covered on every CI platform, Windows included. + +## Related + +- [`docs/specifications/frontend/render-tree.md`](../../../docs/specifications/frontend/render-tree.md) — the node vocabulary and the graceful-fallback rule. +- `pkg/frontend.FallbackText` — the fallback traversal this package delegates to rather than reimplementing. diff --git a/internal/tui/paint/doc.go b/internal/tui/paint/doc.go new file mode 100644 index 0000000..51ae097 --- /dev/null +++ b/internal/tui/paint/doc.go @@ -0,0 +1,18 @@ +// Package paint renders a RenderTree into styled terminal text. +// +// The painter is a pure function of (node, theme, options): it holds no +// terminal state, opens no files, and never consults the clock. That is what +// lets the entire node vocabulary — including the fallback behavior for node +// types this build does not recognize — be tested headlessly on every CI +// platform, Windows included. +// +// Two protocol obligations shape the implementation. First, a frontend MUST +// render every node type gracefully, including a variant added to the enum +// after the frontend shipped, rather than erroring or dropping content; the +// painter delegates that case to pkg/frontend.FallbackText rather than +// reimplementing the traversal. Second, a render failure on one node MUST NOT +// crash the frontend process, so the painter degrades a bad subtree to +// fallback text and keeps going. Both rules are stated in +// docs/specifications/frontend/render-tree.md and the error taxonomy in +// docs/specifications/frontend/frontend-protocol.md. +package paint diff --git a/internal/tui/paint/paint.go b/internal/tui/paint/paint.go new file mode 100644 index 0000000..766267e --- /dev/null +++ b/internal/tui/paint/paint.go @@ -0,0 +1,330 @@ +package paint + +import ( + "fmt" + "strconv" + "strings" + + "charm.land/lipgloss/v2" + + "github.com/pluggableharness/agent/internal/tui/theme" + "github.com/pluggableharness/agent/internal/tui/ui" + "github.com/pluggableharness/agent/pkg/frontend" + renderv1 "github.com/pluggableharness/agent/pkg/render/proto/v1" +) + +// minWidth is the narrowest column count the painter will lay out against. +// Below this, wrapping produces more line breaks than content, so the painter +// clamps rather than degenerating. +const minWidth = 8 + +// Opts carries the per-paint state the shell owns: how wide to lay out, which +// action is under the cursor, and which collapsible paths the operator has +// toggled open. +type Opts struct { + // Width is the available column count. + Width int + // FocusedAction is the ActionNode ID currently under the action cursor. + // Empty means no action is focused in this region. + FocusedAction string + // Expanded maps a node path to a forced expansion state, overriding a + // CollapsibleNode's collapsed_by_default. Paths are assigned by Walk and + // are stable for a given tree shape. + Expanded map[string]bool +} + +func (o Opts) width() int { + if o.Width < minWidth { + return minWidth + } + + return o.Width +} + +// Painter renders nodes using one theme. +type Painter struct { + th theme.Theme +} + +// New returns a Painter that renders with th. +func New(th theme.Theme) *Painter { return &Painter{th: th} } + +// Theme returns the theme this painter renders with. +func (p *Painter) Theme() theme.Theme { return p.th } + +// Tree renders a whole tree. A nil tree or a tree with no root paints as empty +// rather than as an error — an absent tree is a legitimate state, not a fault. +func (p *Painter) Tree(t *renderv1.RenderTree, o Opts) string { + return p.TreeAt(t, o, "0") +} + +// TreeAt renders a tree rooted at an explicit path. A region holding several +// producers' trees gives each one a distinct root path, so the paths that +// Opts.Expanded and Targets key against stay unique across the whole region. +func (p *Painter) TreeAt(t *renderv1.RenderTree, o Opts, rootPath string) string { + if t == nil || t.GetRoot() == nil { + return "" + } + + return p.Node(t.GetRoot(), o, rootPath) +} + +// Node renders a single node and its descendants. The path identifies this +// node's position in the tree and is what Opts.Expanded keys against. +func (p *Painter) Node(n *renderv1.RenderNode, o Opts, path string) string { + if n == nil { + return "" + } + + switch node := n.GetNode().(type) { + case *renderv1.RenderNode_Text: + return p.text(node.Text, o) + case *renderv1.RenderNode_CodeBlock: + return p.codeBlock(node.CodeBlock, o) + case *renderv1.RenderNode_Diff: + return p.diff(node.Diff, o) + case *renderv1.RenderNode_Table: + return p.table(node.Table, o) + case *renderv1.RenderNode_Link: + return p.link(node.Link) + case *renderv1.RenderNode_List: + return p.list(node.List, o, path) + case *renderv1.RenderNode_Group: + return p.group(node.Group, o, path) + case *renderv1.RenderNode_Collapsible: + return p.collapsible(node.Collapsible, o, path) + case *renderv1.RenderNode_SubSession: + return p.subSession(node.SubSession, o) + case *renderv1.RenderNode_Action: + return p.action(node.Action, o) + default: + // A variant added to the enum after this build shipped. The protocol + // requires graceful rendering rather than an error or a silent drop, + // and pkg/frontend already implements exactly that traversal. + return p.th.Dim.Width(o.width()).Render(frontend.FallbackText(n)) + } +} + +func (p *Painter) text(n *renderv1.TextNode, o Opts) string { + return p.th.TextStyle(n.Style).Width(o.width()).Render(ui.ExpandTabs(n.GetContent())) +} + +func (p *Painter) codeBlock(n *renderv1.CodeBlockNode, o Opts) string { + var b strings.Builder + + if lang := n.GetLanguage(); lang != "" { + b.WriteString(p.th.Dim.Render(lang)) + b.WriteString("\n") + } + + // The block is indented rather than boxed so that a code block nested in a + // list or collapsible does not fight the parent's own indentation. + body := p.th.CodeBlock.Width(o.width() - 2).Render(ui.ExpandTabs(n.GetContent())) + b.WriteString(indent(body, " ")) + + return b.String() +} + +func (p *Painter) diff(n *renderv1.DiffNode, o Opts) string { + lines := make([]string, 0, len(n.GetHunks())) + + for _, h := range n.GetHunks() { + header := fmt.Sprintf("@@ -%d,%d +%d,%d @@", + h.GetOldStart(), h.GetOldLines(), h.GetNewStart(), h.GetNewLines()) + lines = append(lines, p.th.DiffHeader.Render(header)) + + for _, l := range h.GetLines() { + lines = append(lines, p.diffLine(l)) + } + } + + // Diff lines are truncated rather than wrapped: a wrapped diff line loses + // the column alignment that makes the +/- gutter readable. + return lipgloss.NewStyle().MaxWidth(o.width()).Render(strings.Join(lines, "\n")) +} + +func (p *Painter) diffLine(l *renderv1.DiffLine) string { + switch l.GetOp() { + case renderv1.DiffLineOp_DIFF_LINE_OP_ADD: + return p.th.DiffAdd.Render("+" + ui.ExpandTabs(l.GetText())) + case renderv1.DiffLineOp_DIFF_LINE_OP_REMOVE: + return p.th.DiffRemove.Render("-" + ui.ExpandTabs(l.GetText())) + case renderv1.DiffLineOp_DIFF_LINE_OP_CONTEXT, renderv1.DiffLineOp_DIFF_LINE_OP_UNSPECIFIED: + return p.th.Default.Render(" " + ui.ExpandTabs(l.GetText())) + default: + return p.th.Default.Render(" " + ui.ExpandTabs(l.GetText())) + } +} + +func (p *Painter) table(n *renderv1.TableNode, o Opts) string { + headers := n.GetHeaders() + rows := n.GetRows() + + widths := make([]int, len(headers)) + for i, h := range headers { + widths[i] = lipgloss.Width(ui.ExpandTabs(h)) + } + + for _, r := range rows { + for i, c := range r.GetCells() { + if w := lipgloss.Width(ui.ExpandTabs(c)); i < len(widths) && w > widths[i] { + widths[i] = w + } + } + } + + out := make([]string, 0, len(rows)+1) + if len(headers) > 0 { + out = append(out, p.th.TableHeader.Render(joinCells(headers, widths))) + } + + for _, r := range rows { + out = append(out, p.th.Default.Render(joinCells(r.GetCells(), widths))) + } + + // Columns are truncated, not wrapped, for the same alignment reason diffs + // are: a wrapped cell breaks the grid the table exists to convey. + return lipgloss.NewStyle().MaxWidth(o.width()).Render(strings.Join(out, "\n")) +} + +func joinCells(cells []string, widths []int) string { + parts := make([]string, 0, len(cells)) + + for i, c := range cells { + c = ui.ExpandTabs(c) + w := lipgloss.Width(c) + if i < len(widths) && widths[i] > w { + c += strings.Repeat(" ", widths[i]-w) + } + + parts = append(parts, c) + } + + return strings.Join(parts, " ") +} + +func (p *Painter) link(n *renderv1.LinkNode) string { + // OSC 8 hyperlinks are emitted unconditionally: terminals that do not + // understand the sequence ignore it and show the label, so there is no + // capability check to get wrong. The URL is appended dimmed so the target + // stays visible in a terminal that swallowed the escape. + label := p.th.Link.Render(ui.ExpandTabs(n.GetText())) + if n.GetUrl() == "" { + return label + } + + return label + p.th.Dim.Render(" ("+n.GetUrl()+")") +} + +func (p *Painter) list(n *renderv1.ListNode, o Opts, path string) string { + items := n.GetItems() + out := make([]string, 0, len(items)) + + inner := o + inner.Width = o.width() - 3 + + for i, item := range items { + marker := "• " + if n.GetOrdered() { + marker = strconv.Itoa(i+1) + ". " + } + + body := p.Node(item, inner, path+"."+strconv.Itoa(i)) + out = append(out, hangingIndent(body, marker)) + } + + return strings.Join(out, "\n") +} + +func (p *Painter) group(n *renderv1.GroupNode, o Opts, path string) string { + children := n.GetChildren() + out := make([]string, 0, len(children)) + + // A group is a transparent container: no border, no indent, no label. + // Adding chrome here would contradict what the node type means. + for i, c := range children { + out = append(out, p.Node(c, o, path+"."+strconv.Itoa(i))) + } + + return strings.Join(out, "\n") +} + +func (p *Painter) collapsible(n *renderv1.CollapsibleNode, o Opts, path string) string { + expanded := !n.GetCollapsedByDefault() + if forced, ok := o.Expanded[path]; ok { + expanded = forced + } + + marker := "▸ " + if expanded { + marker = "▾ " + } + + head := p.th.RegionTitle.Render(marker + ui.ExpandTabs(n.GetSummary())) + if !expanded { + return head + } + + children := n.GetChildren() + out := make([]string, 0, len(children)+1) + out = append(out, head) + + inner := o + inner.Width = o.width() - 2 + + for i, c := range children { + out = append(out, indent(p.Node(c, inner, path+"."+strconv.Itoa(i)), " ")) + } + + return strings.Join(out, "\n") +} + +func (p *Painter) subSession(n *renderv1.SubSessionNode, o Opts) string { + // Deliberately a pointer, never inlined: the protocol defines this node as + // a reference to a nested transcript, not a place to expand one. + label := n.GetSummary() + if label == "" { + label = "sub-session" + } + + return p.th.SubSession.Width(o.width()).Render("⤷ " + label + " (" + n.GetSessionId() + ")") +} + +func (p *Painter) action(n *renderv1.ActionNode, o Opts) string { + style := p.th.Action + if n.GetId() != "" && n.GetId() == o.FocusedAction { + style = p.th.ActionFocused + } + + return style.Render("[ " + ui.ExpandTabs(n.GetLabel()) + " ]") +} + +// indent prefixes every line of s with pad. +func indent(s, pad string) string { + lines := strings.Split(s, "\n") + for i := range lines { + lines[i] = pad + lines[i] + } + + return strings.Join(lines, "\n") +} + +// hangingIndent prefixes the first line with marker and subsequent lines with +// an equivalent run of spaces, so wrapped list items stay aligned under their +// own text rather than under the bullet. +func hangingIndent(s, marker string) string { + lines := strings.Split(s, "\n") + pad := strings.Repeat(" ", lipgloss.Width(marker)) + + for i := range lines { + if i == 0 { + lines[i] = marker + lines[i] + + continue + } + + lines[i] = pad + lines[i] + } + + return strings.Join(lines, "\n") +} diff --git a/internal/tui/paint/paint_test.go b/internal/tui/paint/paint_test.go new file mode 100644 index 0000000..9b7d0d7 --- /dev/null +++ b/internal/tui/paint/paint_test.go @@ -0,0 +1,214 @@ +package paint_test + +import ( + "regexp" + "strings" + "testing" + + "github.com/pluggableharness/agent/internal/tui/paint" + "github.com/pluggableharness/agent/internal/tui/theme" + "github.com/pluggableharness/agent/pkg/render" + renderv1 "github.com/pluggableharness/agent/pkg/render/proto/v1" +) + +func newPainter() *paint.Painter { return paint.New(theme.Dark()) } + +func wide() paint.Opts { return paint.Opts{Width: 60} } + +// ansiPattern matches SGR escape sequences. Lip Gloss may emit them per +// character (underlined text does), so assertions strip them before matching. +var ansiPattern = regexp.MustCompile(`\x1b\[[0-9;]*[a-zA-Z]`) + +func plain(s string) string { return ansiPattern.ReplaceAllString(s, "") } + +// contains reports whether the rendered output contains want, ignoring the +// styling escapes Lip Gloss may have wrapped it in. +func contains(t *testing.T, got, want string) { + t.Helper() + + if !strings.Contains(plain(got), want) { + t.Fatalf("rendered output missing %q\ngot: %q", want, plain(got)) + } +} + +// Every node type must produce visible output. A frontend MUST render every +// node type gracefully rather than dropping content it has no special +// treatment for. +func TestEveryNodeTypeRendersItsContent(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + node *renderv1.RenderNode + want string + }{ + {"text", render.Text("plain words"), "plain words"}, + {"styled text", render.TextStyled("loud", renderv1.TextStyle_TEXT_STYLE_BOLD), "loud"}, + {"code block", render.Code("go", "package main"), "package main"}, + {"code block language", render.Code("go", "x"), "go"}, + {"link text", render.Link("Anthropic", "https://example.test"), "Anthropic"}, + {"link url", render.Link("Anthropic", "https://example.test"), "example.test"}, + {"table header", render.Table([]string{"name"}, [][]string{{"row"}}), "name"}, + {"table cell", render.Table([]string{"name"}, [][]string{{"row"}}), "row"}, + {"list item", render.List(render.Text("only")), "only"}, + {"group child", render.Group(render.Text("inside")), "inside"}, + {"collapsible summary", render.Collapsible("summary", render.Text("child")), "summary"}, + {"sub-session summary", render.SubSession("session-1", "child work"), "child work"}, + {"sub-session id", render.SubSession("session-1", "child work"), "session-1"}, + {"action label", render.Action("a1", "Compact", "compact", nil, "builtin"), "Compact"}, + } + + p := newPainter() + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + contains(t, p.Tree(render.Tree(tc.node), wide()), tc.want) + }) + } +} + +func TestDiffRendersEveryLineOpAndHunkHeader(t *testing.T) { + t.Parallel() + + node := render.Diff(render.Hunk(1, 2, 1, 3, + render.DiffContextLine("context line"), + render.DiffAddLine("added line"), + render.DiffRemoveLine("removed line"), + )) + + got := newPainter().Tree(render.Tree(node), wide()) + + for _, want := range []string{"@@ -1,2 +1,3 @@", "context line", "added line", "removed line", "+", "-"} { + contains(t, got, want) + } +} + +// A node whose variant this build does not recognize must still render rather +// than erroring or crashing the process. +func TestUnknownNodeVariantDoesNotPanicOrError(t *testing.T) { + t.Parallel() + + got := newPainter().Tree(render.Tree(&renderv1.RenderNode{}), wide()) + + if strings.Contains(got, "panic") { + t.Fatalf("unexpected output for unknown variant: %q", got) + } +} + +func TestNilTreeAndNilNodeRenderEmpty(t *testing.T) { + t.Parallel() + + p := newPainter() + + if got := p.Tree(nil, wide()); got != "" { + t.Fatalf("nil tree rendered %q, want empty", got) + } + + if got := p.Tree(&renderv1.RenderTree{}, wide()); got != "" { + t.Fatalf("rootless tree rendered %q, want empty", got) + } + + if got := p.Node(nil, wide(), "0"); got != "" { + t.Fatalf("nil node rendered %q, want empty", got) + } +} + +func TestOrderedListNumbersItems(t *testing.T) { + t.Parallel() + + node := render.OrderedList(render.Text("alpha"), render.Text("beta")) + got := newPainter().Tree(render.Tree(node), wide()) + + contains(t, got, "1. ") + contains(t, got, "2. ") + contains(t, got, "alpha") + contains(t, got, "beta") +} + +func TestUnorderedListUsesBullets(t *testing.T) { + t.Parallel() + + got := newPainter().Tree(render.Tree(render.List(render.Text("alpha"))), wide()) + contains(t, got, "•") +} + +// A group is a transparent container: no border, no indent, no label. Adding +// chrome would contradict what the node type means. +func TestGroupAddsNoChrome(t *testing.T) { + t.Parallel() + + p := newPainter() + + grouped := p.Tree(render.Tree(render.Group(render.Text("a"), render.Text("b"))), wide()) + separate := p.Tree(render.Tree(render.Text("a")), wide()) + "\n" + + p.Tree(render.Tree(render.Text("b")), wide()) + + if grouped != separate { + t.Fatalf("group added chrome\ngrouped: %q\nseparate: %q", grouped, separate) + } +} + +func TestCollapsibleRespectsDefaultAndOverride(t *testing.T) { + t.Parallel() + + collapsed := render.CollapsedByDefault("summary", render.Text("hidden child")) + expanded := render.Collapsible("summary", render.Text("shown child")) + p := newPainter() + + if got := p.Tree(render.Tree(collapsed), wide()); strings.Contains(plain(got), "hidden child") { + t.Fatalf("collapsed_by_default node showed its children: %q", got) + } + + if got := p.Tree(render.Tree(expanded), wide()); !strings.Contains(plain(got), "shown child") { + t.Fatalf("expanded node hid its children: %q", got) + } + + // An explicit override wins over the node's own default, in both directions. + o := wide() + o.Expanded = map[string]bool{"0": true} + + contains(t, p.Tree(render.Tree(collapsed), o), "hidden child") + + o.Expanded = map[string]bool{"0": false} + + if got := p.Tree(render.Tree(expanded), o); strings.Contains(plain(got), "shown child") { + t.Fatalf("override failed to collapse an expanded-by-default node: %q", got) + } +} + +func TestActionIsStyledDifferentlyWhenFocused(t *testing.T) { + t.Parallel() + + node := render.Action("act_1", "Compact", "compact", nil, "builtin") + p := newPainter() + + unfocused := p.Tree(render.Tree(node), wide()) + + focused := wide() + focused.FocusedAction = "act_1" + got := p.Tree(render.Tree(node), focused) + + if got == unfocused { + t.Fatal("focused action rendered identically to unfocused; the cursor would be invisible") + } +} + +func TestWidthIsClampedRatherThanDegenerating(t *testing.T) { + t.Parallel() + + // A pathological width must not produce one character per line or panic. + got := newPainter().Tree(render.Tree(render.Text("some words here")), paint.Opts{Width: -5}) + if got == "" { + t.Fatal("clamped width dropped content") + } +} + +func TestThemeAccessor(t *testing.T) { + t.Parallel() + + if got := paint.New(theme.Light()).Theme().Name; got != "light" { + t.Fatalf("Theme() = %q, want light", got) + } +} diff --git a/internal/tui/paint/walk.go b/internal/tui/paint/walk.go new file mode 100644 index 0000000..9d96cad --- /dev/null +++ b/internal/tui/paint/walk.go @@ -0,0 +1,96 @@ +package paint + +import ( + "strconv" + + renderv1 "github.com/pluggableharness/agent/pkg/render/proto/v1" +) + +// TargetKind distinguishes what activating a focus target does. +type TargetKind int + +const ( + // TargetAction is an ActionNode. Activating it dispatches an + // action_trigger ClientEvent carrying the node's tool_name, args, and + // provider unchanged, which the protocol states as a MUST. + TargetAction TargetKind = iota + // TargetCollapsible is a CollapsibleNode. Activating it toggles expansion + // locally and sends nothing to the kernel. + TargetCollapsible +) + +// Target is one keyboard-reachable element inside a rendered tree. +type Target struct { + // Path is the node's position in the tree, matching the path scheme the + // painter uses for Opts.Expanded. + Path string + Kind TargetKind + // Action is set only when Kind is TargetAction. + Action *renderv1.ActionNode + // Summary is set only when Kind is TargetCollapsible. + Summary string +} + +// Targets returns every keyboard-reachable element in a tree, in the same +// order the painter emits them, so a cursor index means the same thing to both. +// +// Elements inside a collapsed CollapsibleNode are omitted: content the operator +// cannot see must not be reachable by a cursor that appears to move over +// nothing. The expanded map has the same meaning as Opts.Expanded. +func Targets(t *renderv1.RenderTree, expanded map[string]bool) []Target { + return TargetsAt(t, expanded, "0") +} + +// TargetsAt is Targets rooted at an explicit path, matching Painter.TreeAt so +// a cursor index and a rendered node agree on which element they name. +func TargetsAt(t *renderv1.RenderTree, expanded map[string]bool, rootPath string) []Target { + if t == nil || t.GetRoot() == nil { + return nil + } + + var out []Target + collect(t.GetRoot(), rootPath, expanded, &out) + + return out +} + +func collect(n *renderv1.RenderNode, path string, expanded map[string]bool, out *[]Target) { + if n == nil { + return + } + + switch node := n.GetNode().(type) { + case *renderv1.RenderNode_Action: + *out = append(*out, Target{Path: path, Kind: TargetAction, Action: node.Action}) + case *renderv1.RenderNode_List: + collectChildren(node.List.GetItems(), path, expanded, out) + case *renderv1.RenderNode_Group: + collectChildren(node.Group.GetChildren(), path, expanded, out) + case *renderv1.RenderNode_Collapsible: + *out = append(*out, Target{ + Path: path, + Kind: TargetCollapsible, + Summary: node.Collapsible.GetSummary(), + }) + + if isExpanded(node.Collapsible, path, expanded) { + collectChildren(node.Collapsible.GetChildren(), path, expanded, out) + } + default: + // Every other variant is a leaf with nothing to focus. + } +} + +func collectChildren(children []*renderv1.RenderNode, path string, expanded map[string]bool, out *[]Target) { + for i, c := range children { + collect(c, path+"."+strconv.Itoa(i), expanded, out) + } +} + +func isExpanded(n *renderv1.CollapsibleNode, path string, expanded map[string]bool) bool { + if forced, ok := expanded[path]; ok { + return forced + } + + return !n.GetCollapsedByDefault() +} diff --git a/internal/tui/paint/walk_test.go b/internal/tui/paint/walk_test.go new file mode 100644 index 0000000..ea265a1 --- /dev/null +++ b/internal/tui/paint/walk_test.go @@ -0,0 +1,156 @@ +package paint_test + +import ( + "testing" + + "github.com/pluggableharness/agent/internal/tui/paint" + "github.com/pluggableharness/agent/pkg/render" + renderv1 "github.com/pluggableharness/agent/pkg/render/proto/v1" +) + +func paths(in []paint.Target) []string { + out := make([]string, 0, len(in)) + for _, t := range in { + out = append(out, t.Path) + } + + return out +} + +func samePaths(a, b []string) bool { + if len(a) != len(b) { + return false + } + + for i := range a { + if a[i] != b[i] { + return false + } + } + + return true +} + +func TestTargetsFindsActionsInPaintOrder(t *testing.T) { + t.Parallel() + + tree := render.Tree(render.Group( + render.Text("not focusable"), + render.Action("a1", "First", "tool_a", nil, "p"), + render.List( + render.Action("a2", "Second", "tool_b", nil, "p"), + render.Text("also not focusable"), + render.Action("a3", "Third", "tool_c", nil, "p"), + ), + )) + + got := paint.Targets(tree, nil) + if len(got) != 3 { + t.Fatalf("got %d targets, want 3: %+v", len(got), got) + } + + for i, want := range []string{"a1", "a2", "a3"} { + if got[i].Kind != paint.TargetAction { + t.Fatalf("target %d kind = %v, want action", i, got[i].Kind) + } + + if got[i].Action.GetId() != want { + t.Errorf("target %d id = %q, want %q", i, got[i].Action.GetId(), want) + } + } + + // Paths must be unique and match the painter's scheme so a cursor index + // and a rendered node agree on which element they name. + if want := []string{"0.1", "0.2.0", "0.2.2"}; !samePaths(paths(got), want) { + t.Fatalf("paths = %v, want %v", paths(got), want) + } +} + +// Content the operator cannot see must not be reachable by a cursor that +// appears to move over nothing. +func TestTargetsSkipsCollapsedChildren(t *testing.T) { + t.Parallel() + + tree := render.Tree(render.CollapsedByDefault("hidden", + render.Action("buried", "Buried", "tool", nil, "p"), + )) + + got := paint.Targets(tree, nil) + if len(got) != 1 { + t.Fatalf("got %d targets, want only the collapsible itself: %+v", len(got), got) + } + + if got[0].Kind != paint.TargetCollapsible || got[0].Summary != "hidden" { + t.Fatalf("expected the collapsible as the sole target, got %+v", got[0]) + } + + // Expanding it exposes the child. + expanded := paint.Targets(tree, map[string]bool{"0": true}) + if len(expanded) != 2 { + t.Fatalf("expanded: got %d targets, want 2: %+v", len(expanded), expanded) + } + + if expanded[1].Action.GetId() != "buried" { + t.Fatalf("expanded child = %+v, want the buried action", expanded[1]) + } +} + +// An expanded-by-default collapsible can be forced closed, which must also +// withdraw its children from the target list. +func TestTargetsHonorsForcedCollapse(t *testing.T) { + t.Parallel() + + tree := render.Tree(render.Collapsible("shown", + render.Action("child", "Child", "tool", nil, "p"), + )) + + if got := paint.Targets(tree, nil); len(got) != 2 { + t.Fatalf("expanded by default: got %d targets, want 2", len(got)) + } + + if got := paint.Targets(tree, map[string]bool{"0": false}); len(got) != 1 { + t.Fatalf("forced collapsed: got %d targets, want 1", len(got)) + } +} + +func TestTargetsAtUsesTheGivenRootPath(t *testing.T) { + t.Parallel() + + tree := render.Tree(render.Group(render.Action("a", "A", "tool", nil, "p"))) + + got := paint.TargetsAt(tree, nil, "7") + if len(got) != 1 || got[0].Path != "7.0" { + t.Fatalf("TargetsAt rooted wrong: %v", paths(got)) + } +} + +func TestTargetsOnEmptyTrees(t *testing.T) { + t.Parallel() + + if got := paint.Targets(nil, nil); got != nil { + t.Fatalf("nil tree = %v, want nil", got) + } + + if got := paint.Targets(&renderv1.RenderTree{}, nil); got != nil { + t.Fatalf("rootless tree = %v, want nil", got) + } + + if got := paint.Targets(render.Tree(render.Text("leaf")), nil); len(got) != 0 { + t.Fatalf("leaf-only tree = %v, want no targets", got) + } +} + +func TestTargetsNestedCollapsibles(t *testing.T) { + t.Parallel() + + tree := render.Tree(render.Collapsible("outer", + render.Collapsible("inner", + render.Action("deep", "Deep", "tool", nil, "p"), + ), + )) + + got := paint.Targets(tree, nil) + if want := []string{"0", "0.0", "0.0.0"}; !samePaths(paths(got), want) { + t.Fatalf("nested paths = %v, want %v", paths(got), want) + } +} From 15473b979cc403865b3f59ac2e92d2784a49c78b Mon Sep 17 00:00:00 2001 From: Steven Crothers Date: Mon, 27 Jul 2026 10:04:54 -0400 Subject: [PATCH 05/11] =?UTF-8?q?tui:=20add=20the=20shell=20package=20?= =?UTF-8?q?=E2=80=94=20the=20Bubble=20Tea=20model?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The frontend itself: layout solver, focus ring, keymap layers, agent switcher, composer, status bar, and the overlay that carries a plan/apply decision. Layout arithmetic lives in exactly one place. Layout.ComposerTop is the single source for where the bands stack, because a duplicate of it in the cursor path already drifted once — the header grew from a line into a bordered box, the frame accounted for it and the cursor did not, and the caret sat two rows above its text. The frame tests assert against painted output rather than recomputed arithmetic so that class of drift fails loudly. Full-screen takeover is entirely View properties in Bubble Tea v2 — AltScreen, BackgroundColor, WindowTitle, Cursor, MouseMode — and there are no matching program options. MouseMode is what makes the wheel scroll the transcript; without it the wheel silently scrolls the terminal's own buffer behind the alt screen, which looks like it works while doing something else. An overlay captures the keyboard entirely, and esc explicitly does not resolve a pending decision: turning "go away" into an allow or a deny on the operator's behalf would be indefensible. demo.go is a fixture with an expiry date. It exists only because the kernel-side attach path does not yet; delete it when the real bridge lands rather than growing it into a second implementation. --- internal/tui/shell/CLAUDE.md | 52 ++ internal/tui/shell/README.md | 46 + internal/tui/shell/agent.go | 65 ++ internal/tui/shell/agent_test.go | 210 +++++ internal/tui/shell/demo.go | 164 ++++ internal/tui/shell/doc.go | 21 + internal/tui/shell/event.go | 227 +++++ internal/tui/shell/focus.go | 77 ++ internal/tui/shell/focus_test.go | 108 +++ internal/tui/shell/format.go | 132 +++ internal/tui/shell/format_test.go | 112 +++ internal/tui/shell/input.go | 178 ++++ internal/tui/shell/input_test.go | 323 +++++++ internal/tui/shell/keymap.go | 180 ++++ internal/tui/shell/keymap_test.go | 147 +++ internal/tui/shell/layout.go | 186 ++++ internal/tui/shell/layout_test.go | 218 +++++ internal/tui/shell/model.go | 1275 +++++++++++++++++++++++++ internal/tui/shell/model_test.go | 1449 +++++++++++++++++++++++++++++ 19 files changed, 5170 insertions(+) create mode 100644 internal/tui/shell/CLAUDE.md create mode 100644 internal/tui/shell/README.md create mode 100644 internal/tui/shell/agent.go create mode 100644 internal/tui/shell/agent_test.go create mode 100644 internal/tui/shell/demo.go create mode 100644 internal/tui/shell/doc.go create mode 100644 internal/tui/shell/event.go create mode 100644 internal/tui/shell/focus.go create mode 100644 internal/tui/shell/focus_test.go create mode 100644 internal/tui/shell/format.go create mode 100644 internal/tui/shell/format_test.go create mode 100644 internal/tui/shell/input.go create mode 100644 internal/tui/shell/input_test.go create mode 100644 internal/tui/shell/keymap.go create mode 100644 internal/tui/shell/keymap_test.go create mode 100644 internal/tui/shell/layout.go create mode 100644 internal/tui/shell/layout_test.go create mode 100644 internal/tui/shell/model.go create mode 100644 internal/tui/shell/model_test.go diff --git a/internal/tui/shell/CLAUDE.md b/internal/tui/shell/CLAUDE.md new file mode 100644 index 0000000..6344997 --- /dev/null +++ b/internal/tui/shell/CLAUDE.md @@ -0,0 +1,52 @@ +# internal/tui/shell — agent notes + +## The shell must never write to stdout or read stdin + +As a `hashicorp/go-plugin` subprocess the shell's stdout carries the handshake and is piped into the host's logger. Painting there corrupts the handshake; reading stdin competes with the plugin transport. `cmd/tui` opens the controlling terminal and passes it as both `tea.WithInput` and `tea.WithOutput`. Never add a `fmt.Println` anywhere in this package, and never remove those program options. + +## Bubble Tea v2 specifics that differ from v1 + +- The module path is `charm.land/bubbletea/v2`, not `github.com/charmbracelet/...`. +- `Model.View()` returns a `tea.View`, not a string, and **alt-screen is a property of that View** — there is no `tea.WithAltScreen()` program option. Every returned View sets `AltScreen`, including the empty one painted while quitting. +- Keys arrive as `tea.KeyPressMsg`. Its `String()` returns the literal text for printable keys and the keystroke name otherwise, which is why space arrives as `"space"` and is handled by its own case rather than by the printable check. +- Full-screen takeover is entirely `View` properties: `AltScreen`, `BackgroundColor`/`ForegroundColor`, `WindowTitle`, `Cursor`, and `MouseMode`. There are no matching program options. + +## Takeover is only complete if the mouse is claimed + +`View.MouseMode = tea.MouseModeCellMotion` is what makes the wheel scroll the transcript. Drop it and the wheel silently scrolls the terminal's buffer behind the alt screen — the gesture appears to work while doing something else entirely, which is worse than not handling it. `TestViewClaimsTheTerminal` asserts every takeover property; the cost of the mode is that drag-selection needs the terminal's shift+drag override. + +Scroll offset tracks the tail while pinned (see `window`), so the first scroll away from the live edge starts where the operator is looking rather than jumping to the top. + +## shift+enter needs key disambiguation + +A bare terminal sends CR for both `enter` and `shift+enter`. Bubble Tea negotiates the Kitty keyboard protocol and `modifyOtherKeys` level 2 at startup, which makes them distinguishable where supported. `alt+enter` and `ctrl+j` are bound to the same action as fallbacks — `ctrl+j` is literally line feed and always works. Do not drop them. + +## The cursor's row comes from Layout, never from local arithmetic + +`Layout.ComposerTop()` is the single place that knows how the frame's bands stack. `cursorScreenPos` derives from it rather than re-deriving the sum, because the two versions already drifted once: the header grew from a single line into a bordered box, `frame()` accounted for it and the cursor did not, and the caret sat two rows above the text it belonged to. + +`TestCursorLandsOnTheComposerRow` asserts against the *painted frame* — it finds the row containing the prompt and checks the cursor is on it — rather than against the arithmetic, which is what makes that class of mistake fail loudly instead of silently. + +## Keep constants in sync with the design doc + +`layout.go`'s breakpoints are documented in `docs/first-party/frontends/tui.md`. They are the same numbers stated twice; change both or the doc becomes a lie. + +## Overlay modality is a correctness property, not styling + +While an overlay is up it captures the keyboard entirely and the focus ring is suspended. `esc` explicitly does **not** resolve a pending decision — turning "go away" into an allow or a deny on the operator's behalf would be indefensible, and there is a test named for it. The protocol also requires overlay content be visually distinct from ambient content. + +## Action nodes dispatch unchanged + +`activate` passes `tool_name`, `args`, and `provider` through verbatim. The protocol states this as a MUST. Do not normalize, default, or reinterpret them on the way out. + +## `regionKey` returning false is what makes layering work + +A region handler that returns `false` lets the key fall through to the global layer. A handler that swallows everything would make `ctrl+c` unreachable from the composer. Keep the `default: return false` branches. + +## This package is pure domain + +No `log/slog`, no `internal/telemetry`, no I/O — the pure-domain exemption in `.claude/rules/logging-telemetry.md` applies. Logging belongs in `cmd/tui`, which is where the process boundary actually is. + +## `demo.go` is a fixture with an expiry date + +It exists only because the kernel-side attach path does not. Delete it when the real bridge lands rather than growing it into a second implementation. diff --git a/internal/tui/shell/README.md b/internal/tui/shell/README.md new file mode 100644 index 0000000..aa16d3e --- /dev/null +++ b/internal/tui/shell/README.md @@ -0,0 +1,46 @@ +# internal/tui/shell + +The reference TUI's frame: layout, focus, keymap, and the composition of every region into one painted view. + +## What lives here + +| File | Owns | +|---|---| +| `layout.go` | Geometry: which regions fit, how much room each gets, and the fixed order they are dropped in as space runs out | +| `focus.go` | The focus ring and its cycling rules | +| `keymap.go` | Bindings, the three-layer precedence stack, and the hint line those layers generate | +| `agent.go` | The selectable agent roster and the ring `shift+tab` cycles | +| `format.go` | Status-bar value formatting: tokens, cost, duration, percentages | +| `input.go` | The composer buffer: editing, multi-line, and prompt history | +| `event.go` | The message vocabulary and the `EventSource` seam | +| `model.go` | The Bubble Tea model that ties them together | +| `demo.go` | A scripted source that runs the shell without a kernel | + +## The four gaps this package fills + +The frontend protocol defines what content arrives and where it is placed, and deliberately stops there. It specifies no focus model, no keybinding schema, no resize semantics, and no scrollback behavior. This package answers all four for the terminal: + +- **Focus** targets regions, not nodes. Within a focused region an action cursor selects among reachable elements. Overlay is modal and captures the keyboard outright. +- **Keybindings** resolve overlay → focused region → global, with a handled key stopping propagation. +- **Resize** drops regions in a documented order — sidebar, then hints, then top bar — with `main_chat` and `input_bar` never dropped. +- **Scrollback** pins to the live tail unless the operator scrolls away from it, and the mouse wheel is claimed so it scrolls the transcript rather than the terminal behind the alt screen. + +Session data is placed by **how fast it changes**: where the work is (directory, repository) in the top bar, settings that move with the agent (agent, model, thinking, effort) in the composer title, detail (`usage`) in a sidebar panel the shell contributes itself, and only volatile state — context, cache, cost, elapsed — on the single line beneath the composer. Version-control detail is left to a git widget rather than duplicated in shell chrome. Space beside the input is the most-looked-at part of the screen and goes to what actually moves. + +The transcript is bottom-anchored for the same reason: the newest message belongs next to the composer, with empty space above rather than between them. + +Only some of these fields have a protocol source; the rest arrive as messages (`WorkspaceMsg`, `EditStatsMsg`) precisely because the shell performs no I/O and so cannot discover them. See `format.go` for rendering rules and the design doc for the full provenance table. + +It also carries the active **agent** (`Code`/`Plan`/`Chat` by default), cycled with `shift+tab`. An agent is a name plus a theme *tone*, so a roster can come from configuration without naming colors; see `agent.go` and the design doc. + +The operator-facing version of these decisions is [`docs/first-party/frontends/tui.md`](../../../docs/first-party/frontends/tui.md). The constants here are what that document describes; changing one means changing both. + +## Testability + +`Model` performs no I/O and never touches a terminal. An `EventSource` translates whatever it is attached to into the message vocabulary in `event.go`, and operator actions leave through an emitter callback. Every behavior — key routing, focus cycling, responsive dropping, overlay modality — is exercised by calling `Update` directly, with no TTY and no kernel. + +`EventSource` is declared here rather than beside an implementation because this is where it is consumed and the shell needs exactly one method of it. + +## Not built yet + +No kernel-side code launches a frontend plugin and drives its `Attach` stream, so `DemoSource` is currently the only source. It is a fixture, not a shipping path. diff --git a/internal/tui/shell/agent.go b/internal/tui/shell/agent.go new file mode 100644 index 0000000..409cc4c --- /dev/null +++ b/internal/tui/shell/agent.go @@ -0,0 +1,65 @@ +package shell + +import "github.com/pluggableharness/agent/internal/tui/theme" + +// Agent is one selectable agent profile: what it is called and how it is +// colored. +// +// This is deliberately plain data rather than behavior. The roster is expected +// to come from configuration — an `agent_profile` block naming a tone — so +// nothing here may depend on the three built-in entries existing. +// +// Tone is a role, not a color: config says `color = "accent"` and the active +// theme decides what that means, which is what keeps a custom theme able to +// recolor agents along with everything else. +type Agent struct { + Name string + Tone theme.Tone + // Description is shown when the roster is presented as a list. It is + // optional; an empty value simply renders nothing. + Description string +} + +// DefaultAgents is the roster the shell falls back to when configuration +// supplies none. +// +// The three entries are the demo set, not a protocol-defined vocabulary. The +// tones are chosen to be distinguishable at a glance rather than decorative: +// building is the ordinary mode, planning is the careful read-only one and +// borrows the color the rest of the UI already uses for "worth your attention", +// and chat is the one that changes nothing. +var DefaultAgents = []Agent{ + {Name: "Code", Tone: theme.TonePrimary, Description: "build and edit"}, + {Name: "Plan", Tone: theme.ToneWarning, Description: "read-only, no tools applied"}, + {Name: "Chat", Tone: theme.ToneInfo, Description: "conversation only"}, +} + +// agentRing holds the selectable roster and which entry is active. +type agentRing struct { + agents []Agent + active int +} + +func newAgentRing(agents []Agent) agentRing { + if len(agents) == 0 { + agents = DefaultAgents + } + + return agentRing{agents: agents} +} + +// Current returns the active agent. The roster is never empty — a caller that +// supplies none gets the default set — so this always has something to return. +func (r agentRing) Current() Agent { return r.agents[r.active] } + +// cycle advances the selection, wrapping at either end. +func (r *agentRing) cycle(back bool) Agent { + step := 1 + if back { + step = -1 + } + + r.active = (r.active + step + len(r.agents)) % len(r.agents) + + return r.Current() +} diff --git a/internal/tui/shell/agent_test.go b/internal/tui/shell/agent_test.go new file mode 100644 index 0000000..e28d3d8 --- /dev/null +++ b/internal/tui/shell/agent_test.go @@ -0,0 +1,210 @@ +package shell + +import ( + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + + "github.com/pluggableharness/agent/internal/tui/theme" +) + +func TestDefaultRosterIsDistinguishable(t *testing.T) { + t.Parallel() + + th := theme.Dark() + + seenName := map[string]bool{} + seenColor := map[string]bool{} + + for _, a := range DefaultAgents { + if seenName[a.Name] { + t.Errorf("duplicate agent name %q", a.Name) + } + + seenName[a.Name] = true + + // Two agents sharing a color would make the badge meaningless. + key := th.Tone(a.Tone) + r, g, b, _ := key.RGBA() + id := string(rune(r)) + string(rune(g)) + string(rune(b)) + + if seenColor[id] { + t.Errorf("agent %q shares a color with another agent", a.Name) + } + + seenColor[id] = true + } + + if len(DefaultAgents) != 3 { + t.Fatalf("expected the Code/Plan/Chat demo roster, got %d entries", len(DefaultAgents)) + } +} + +func TestAgentRingCyclesAndWraps(t *testing.T) { + t.Parallel() + + r := newAgentRing(DefaultAgents) + + if got := r.Current().Name; got != "Code" { + t.Fatalf("initial agent = %q, want Code", got) + } + + want := []string{"Plan", "Chat", "Code"} + for i, w := range want { + if got := r.cycle(false).Name; got != w { + t.Fatalf("forward step %d = %q, want %q", i, got, w) + } + } + + // Backward wraps too, even though nothing binds it today. + if got := r.cycle(true).Name; got != "Chat" { + t.Fatalf("backward step = %q, want Chat", got) + } +} + +// An empty roster keeps the defaults: the shell always needs something to +// display as active, and Current must never index an empty slice. +func TestEmptyRosterFallsBackToDefaults(t *testing.T) { + t.Parallel() + + if got := newAgentRing(nil).Current().Name; got != DefaultAgents[0].Name { + t.Fatalf("nil roster gave %q", got) + } + + if got := newAgentRing([]Agent{}).Current().Name; got != DefaultAgents[0].Name { + t.Fatalf("empty roster gave %q", got) + } +} + +func TestShiftTabCyclesAgentAndAnnounces(t *testing.T) { + t.Parallel() + + m, rec := newTestModel(t) + + if got := m.Agent().Name; got != "Code" { + t.Fatalf("startup agent = %q, want Code", got) + } + + press(t, m, "shift+tab") + + if got := m.Agent().Name; got != "Plan" { + t.Fatalf("after shift+tab = %q, want Plan", got) + } + + got, ok := rec.last(t).(AgentSelected) + if !ok { + t.Fatalf("emitted %T, want AgentSelected", rec.last(t)) + } + + if got.Name != "Plan" { + t.Fatalf("AgentSelected.Name = %q, want Plan", got.Name) + } +} + +// shift+tab must no longer move focus: the agent switcher owns it. +func TestShiftTabDoesNotMoveFocus(t *testing.T) { + t.Parallel() + + m, _ := newTestModel(t) + press(t, m, "shift+tab") + + if m.Focus() != FocusInput { + t.Fatalf("shift+tab changed focus to %v", m.Focus()) + } +} + +// Cycling is a global binding, so it works from any focused region rather than +// only from the composer. +func TestAgentCyclesFromAnyRegion(t *testing.T) { + t.Parallel() + + m, _ := newTestModel(t) + press(t, m, "tab") // focus main_chat + + press(t, m, "shift+tab") + + if got := m.Agent().Name; got != "Plan" { + t.Fatalf("agent = %q, want Plan", got) + } + + if m.Focus() != FocusMain { + t.Fatalf("cycling the agent disturbed focus: %v", m.Focus()) + } +} + +func TestActiveAgentIsVisibleInTheFrame(t *testing.T) { + t.Parallel() + + m, _ := newTestModel(t) + press(t, m, "shift+tab") // -> Plan + press(t, m, "x") // clear the transient notice + + got := plain(m.View().Content) + if !strings.Contains(got, "Plan") { + t.Fatalf("active agent not shown in the frame:\n%s", got) + } + + // The composer titles itself with the agent once no notice is pending. + if strings.Contains(got, "Code") { + t.Errorf("previous agent still visible:\n%s", got) + } +} + +// Switching agents changes the composer accent, which is the ambient signal +// that the mode changed. +func TestAgentColorDrivesTheComposerAccent(t *testing.T) { + t.Parallel() + + m, _ := newTestModel(t) + press(t, m, "x") // clear any notice so the accent is the agent's + + code := m.agentColor() + press(t, m, "shift+tab") + press(t, m, "x") + + if m.agentColor() == code { + t.Fatal("agent color did not change with the selection") + } +} + +func TestWithAgentsOverridesTheRoster(t *testing.T) { + t.Parallel() + + custom := []Agent{ + {Name: "Review", Tone: theme.ToneDanger}, + {Name: "Ship", Tone: theme.ToneSuccess}, + } + + m := New(WithAgents(custom)) + m.Update(tea.WindowSizeMsg{Width: 100, Height: 24}) + + if got := m.Agent().Name; got != "Review" { + t.Fatalf("custom roster active agent = %q, want Review", got) + } + + press(t, m, "shift+tab") + + if got := m.Agent().Name; got != "Ship" { + t.Fatalf("after cycle = %q, want Ship", got) + } +} + +// A notice occupies the composer title, so it must not stick around and hide +// the active agent forever. +func TestNoticeClearsOnNextKeystroke(t *testing.T) { + t.Parallel() + + m, _ := newTestModel(t) + m.Update(NoticeMsg{Text: "something happened", Level: NoticeWarn}) + + if !strings.Contains(plain(m.View().Content), "something happened") { + t.Fatal("notice was not shown") + } + + press(t, m, "x") + + if strings.Contains(plain(m.View().Content), "something happened") { + t.Fatal("notice survived a keystroke and would hide the agent title") + } +} diff --git a/internal/tui/shell/demo.go b/internal/tui/shell/demo.go new file mode 100644 index 0000000..dcd1fb7 --- /dev/null +++ b/internal/tui/shell/demo.go @@ -0,0 +1,164 @@ +package shell + +import ( + "context" + "time" + + tea "charm.land/bubbletea/v2" + + "github.com/pluggableharness/agent/internal/tui/region" + "github.com/pluggableharness/agent/pkg/render" + renderv1 "github.com/pluggableharness/agent/pkg/render/proto/v1" +) + +// DemoSource is a scripted EventSource that exercises every region and node +// type without a kernel. +// +// It exists because no kernel-side code launches a frontend plugin and drives +// its Attach stream yet: cmd/agent is non-interactive and the frontend-backed +// interactive driver is still pending. Until that lands this is what makes the +// shell runnable and reviewable. It is a fixture, not a shipping path, and it +// should be deleted the moment the real bridge exists. +type DemoSource struct { + // Step paces the script. Zero means emit everything immediately, which is + // what tests want. + Step time.Duration +} + +// Run implements EventSource. +func (d DemoSource) Run(ctx context.Context, send func(tea.Msg)) error { + for _, msg := range demoScript() { + select { + case <-ctx.Done(): + // Cancellation is ordinary control flow for a stream, never an + // error to report. + return nil + default: + } + + send(msg) + + if d.Step > 0 { + select { + case <-ctx.Done(): + return nil + case <-time.After(d.Step): + } + } + } + + <-ctx.Done() + + return nil +} + +// demoScript is the fixed sequence of messages the demo emits. It is a pure +// function so tests can assert against it without running the source. +func demoScript() []tea.Msg { + kernel := region.Producer{Category: "kernel", Name: "demo"} + // Two distinct widget producers, so the sidebar exercises coexistence and + // priority ordering rather than one widget replacing the other. + gitWidget := region.Producer{Category: "widget", Name: "git"} + jobsWidget := region.Producer{Category: "widget", Name: "jobs"} + + return []tea.Msg{ + StatusMsg{ + Session: "session-01DEMO", + Model: "claude-opus-5", + Status: "ready", + Thinking: "extended", + Effort: "high", + Elapsed: 22 * time.Minute, + }, + + WorkspaceMsg{ + Directory: "~/code/aiagent", + Repository: "pluggableharness/agent", + }, + + EditStatsMsg{LinesRead: 4820, LinesAdded: 612, LinesRemoved: 148}, + + place(kernel, 1, renderv1.Region_REGION_MAIN_CHAT, false, nil, render.Tree(render.Group( + render.TextStyled("Reference TUI shell", renderv1.TextStyle_TEXT_STYLE_BOLD), + render.Text("Every region below is plugin-contributable. This content is a fixture."), + ))), + + place(kernel, 2, renderv1.Region_REGION_MAIN_CHAT, false, nil, render.Tree(render.Collapsible( + "read_file(internal/tui/shell/model.go)", + render.Code("go", "func (m *Model) View() tea.View {\n\t// ...\n}"), + ))), + + place(kernel, 3, renderv1.Region_REGION_MAIN_CHAT, false, nil, render.Tree(render.Diff( + render.Hunk(12, 3, 12, 4, + render.DiffContextLine("func Solve(width, height int) Layout {"), + render.DiffRemoveLine("\treturn Layout{}"), + render.DiffAddLine("\tl := Layout{Width: width}"), + render.DiffAddLine("\treturn l"), + ), + ))), + + place(kernel, 4, renderv1.Region_REGION_MAIN_CHAT, false, nil, render.Tree(render.Group( + render.TextStyled("Interactive content", renderv1.TextStyle_TEXT_STYLE_DIM), + render.Action("act_compact", "Compact context", "compact_context", nil, "builtin"), + ))), + + place(kernel, 5, renderv1.Region_REGION_MAIN_CHAT, false, nil, + render.Tree(render.SubSession("session-01CHILD", "search the codebase"))), + + // No repeated heading: the panel title already names the producer. + // With workspace detail out of shell chrome, the git widget is the only + // source of VCS state — which is the point: one truth, contributed by + // the plugin that owns it. + place(gitWidget, 6, renderv1.Region_REGION_SIDEBAR, true, new(int32(10)), render.Tree(render.Group( + render.Text("feat/tui-shell"), + render.TextStyled("3 modified", renderv1.TextStyle_TEXT_STYLE_WARNING), + render.Text("pr #11"), + render.Action("act_diff", "Review diff", "git_diff", nil, "git"), + ))), + + // A second widget of a different kind: the shell already reports context + // and cost itself, so a fixture that repeated them would demonstrate + // duplication rather than what widgets are for. + place(jobsWidget, 7, renderv1.Region_REGION_SIDEBAR, true, new(int32(20)), render.Tree(render.Group( + render.TextStyled("build ✓ 2.1s", renderv1.TextStyle_TEXT_STYLE_SUCCESS), + render.TextStyled("tests running", renderv1.TextStyle_TEXT_STYLE_WARNING), + ))), + + UsageMsg{ + UsedTokens: 51_204, + EffectiveCeiling: 200_000, + CumulativeCostUSD: 0.42, + InputTokens: 18_400, + OutputTokens: 9_120, + CacheReadTokens: 146_800, + CacheWriteTokens: 22_050, + }, + + DeltaMsg{TargetID: "msg_1", Text: "Streaming text arrives token "}, + DeltaMsg{TargetID: "msg_1", Text: "by token on the fast path."}, + + PermissionMsg{ + ItemID: "item_1", + Title: "Allow write_file(internal/tui/shell/model.go)?", + Preview: render.Tree(render.Diff( + render.Hunk(1, 1, 1, 2, + render.DiffContextLine("package shell"), + render.DiffAddLine("// added by the plan"), + ), + )), + }, + } +} + +func place(p region.Producer, seq uint64, r renderv1.Region, replace bool, priority *int32, tree *renderv1.RenderTree) PlaceMsg { + return PlaceMsg{ + Producer: p, + Sequence: seq, + Content: &renderv1.PlacedContent{ + Region: r, + Content: tree, + Replace: replace, + Priority: priority, + }, + } +} diff --git a/internal/tui/shell/doc.go b/internal/tui/shell/doc.go new file mode 100644 index 0000000..c969e52 --- /dev/null +++ b/internal/tui/shell/doc.go @@ -0,0 +1,21 @@ +// Package shell is the reference TUI's frame: layout, focus, keymap, and the +// composition of every region into one painted view. +// +// The shell fills the four gaps the frontend protocol deliberately leaves to +// the implementation — focus, keybindings, terminal resize, and scrollback — +// none of which appear anywhere in docs/specifications/frontend/. Those +// decisions are documented for operators in +// docs/first-party/frontends/tui.md, and the constants and tables here are the +// authority that document describes; changing one means changing both. +// +// The design is message-driven and I/O-free. Model performs no reads or writes +// and never touches a terminal: an EventSource translates whatever it is +// attached to into the message vocabulary in event.go, and operator actions +// leave through an emitter callback. That is what allows the whole shell — +// including key routing, focus cycling, and responsive region dropping — to be +// exercised by calling Update directly, with no TTY and no kernel. +// +// EventSource is declared in this package rather than beside an implementation +// because this is where it is consumed, and the shell needs exactly one method +// of it. +package shell diff --git a/internal/tui/shell/event.go b/internal/tui/shell/event.go new file mode 100644 index 0000000..2d2092a --- /dev/null +++ b/internal/tui/shell/event.go @@ -0,0 +1,227 @@ +package shell + +import ( + "context" + "time" + + tea "charm.land/bubbletea/v2" + "google.golang.org/protobuf/types/known/structpb" + + "github.com/pluggableharness/agent/internal/tui/region" + renderv1 "github.com/pluggableharness/agent/pkg/render/proto/v1" +) + +// EventSource feeds the shell. It is declared here, in the package that +// consumes it, rather than beside an implementation — the shell needs exactly +// one method and should not depend on a transport to say so. +// +// Run blocks until ctx is canceled or the source is exhausted, delivering +// messages through send. Implementations translate their own inputs into the +// message vocabulary below; no wire type reaches the model unconverted. +type EventSource interface { + Run(ctx context.Context, send func(tea.Msg)) error +} + +// PlaceMsg delivers content for a region. It is the translated form of a +// ServerEvent.render carrying PlacedContent. +type PlaceMsg struct { + Content *renderv1.PlacedContent + Producer region.Producer + Sequence uint64 +} + +// DeltaMsg is streamed model text. Consecutive deltas sharing a TargetID +// accumulate into one growing block rather than separate lines. +type DeltaMsg struct { + TargetID string + Text string +} + +// SettledMsg reports that a streamed block finished, so its live buffer can be +// dropped in favor of the finished render that replaces it. +type SettledMsg struct{ TargetID string } + +// PermissionMsg asks the operator to decide one plan item. The protocol +// requires this be presented in the overlay region with a visual treatment +// distinct from ambient content, and the decision unit is always one item — +// never one answer for a whole plan. +type PermissionMsg struct { + ItemID string + Title string + // Preview is the plan item's preview tree when the provider supplied one. + // When it is nil the shell falls back to rendering the raw input, which the + // plan/apply gate spec requires rather than showing nothing. + Preview *renderv1.RenderTree + RawInput string +} + +// NoticeMsg is an out-of-band message for the operator: an error, a rejected +// client event, or a session status change. Notices are surfaced, never +// silently dropped, because several of the protocol's error categories are +// explicitly required to be visible and distinct. +type NoticeMsg struct { + Text string + Level NoticeLevel +} + +// NoticeLevel classifies a notice for styling. +type NoticeLevel int + +const ( + // NoticeInfo is ordinary progress information. + NoticeInfo NoticeLevel = iota + // NoticeWarn is worth attention short of an error. + NoticeWarn + // NoticeError is a failure. + NoticeError +) + +// StatusMsg updates the top bar and the model line of the status bar. +type StatusMsg struct { + Session string + Model string + Status string + // Thinking and Effort describe the model's reasoning configuration as the + // operator would say it ("extended", "high"). ModelSpec carries a + // ThinkingSpec, but no frontend-facing event exposes it yet — the bridge + // resolves it and passes it through here. + // + // They sit beside the composer rather than in the top bar because they + // change with the selected agent: they are settings, not identity. + Thinking string + Effort string + // Elapsed is how long the session has been running. + // + // It arrives pre-computed rather than being derived from a start time, + // because the model is pure: it never reads the clock. Whatever drives the + // shell decides how often this ticks. + Elapsed time.Duration +} + +// DismissOverlayMsg clears overlay content the shell did not resolve itself — +// the case where another frontend won a decision race and the kernel rejected +// this shell's late response. +type DismissOverlayMsg struct{ Reason string } + +// Action is an operator-originated event destined for the kernel's Attach +// stream. The shell emits these; the bridge translates them to ClientEvents. +type Action interface{ isAction() } + +// SubmitPrompt is a user message. The protocol carries content blocks rather +// than a bare string; the bridge wraps this text in a text block. +type SubmitPrompt struct{ Text string } + +// DecisionScope mirrors the protocol's PlanDecisionScope. +type DecisionScope int + +const ( + // ScopeOnce applies to this item only. It is the default the shell sends + // absent explicit operator intent, which the spec states as a SHOULD. + ScopeOnce DecisionScope = iota + // ScopeSession remembers the verdict for the rest of the session. + ScopeSession + // ScopeAlways persists the verdict as policy. + ScopeAlways +) + +// Decision resolves one plan item. +type Decision struct { + ItemID string + Allow bool + Scope DecisionScope +} + +// Trigger activates an ActionNode. The protocol requires the node's tool name, +// args, and provider be dispatched unchanged, so this carries them verbatim +// rather than reinterpreting them. +type Trigger struct { + NodeID string + ToolName string + Provider string + Args *structpb.Struct +} + +// Interrupt cancels the running turn. Cancellation cascades to the whole +// sub-agent tree, so this is never scoped to a single child. +type Interrupt struct{} + +func (SubmitPrompt) isAction() {} +func (Decision) isAction() {} +func (Trigger) isAction() {} +func (Interrupt) isAction() {} + +// AgentSelected reports that the operator switched agent profile. +// +// No ClientEvent carries this today: the frontend protocol's client-event set +// has no agent-profile variant, and a session's profile is fixed when the +// session is created. The shell therefore keeps the selection as local state +// and emits this so the bridge can decide what it means — most plausibly the +// profile for the next session, or a direct-invoke slash command. Closing that +// gap is a protocol question, not a shell one; see the design doc. +type AgentSelected struct { + Name string +} + +func (AgentSelected) isAction() {} + +// UsageMsg carries the session's context pressure and running cost, translated +// from ServerEvent.usage_update. +// +// The denominator is the *effective ceiling*, not the model's raw context +// window: the ceiling is what remains after the kernel reserves room for +// expected output and tool schemas, and it is the figure the protocol names as +// the one a context-budget indicator should divide against. Showing pressure +// against the raw window would understate it — an operator would read 70% while +// the next turn is already at risk of not fitting. +type UsageMsg struct { + UsedTokens int64 + EffectiveCeiling int64 + CumulativeCostUSD float64 + + // Cumulative token split, from model.v1.Usage. Cache reads are never also + // counted in InputTokens, which is what makes the cache rate below a real + // ratio rather than a double count. + InputTokens int64 + OutputTokens int64 + CacheReadTokens int64 + CacheWriteTokens int64 +} + +// CacheRate is the share of input that came from cache, and whether there was +// enough input to say. It is the number that explains why a turn was cheap. +func (u UsageMsg) CacheRate() (float64, bool) { + total := u.InputTokens + u.CacheReadTokens + if total <= 0 { + return 0, false + } + + return float64(u.CacheReadTokens) / float64(total), true +} + +// WorkspaceMsg describes where the session is working. +// +// None of this is in the protocol: there is no workspace or VCS concept +// anywhere in the wire contracts, and there should not be one invented just to +// feed a status bar. It arrives as a message so the shell stays pure — cmd/tui +// can supply the directory, and a git widget is the natural source for the rest. +// Only what the shell actually renders is carried here. Branch, subtree, and +// pull request are deliberately absent: they are version-control detail, a git +// widget already contributes them as ordinary sidebar content, and duplicating +// them in shell chrome would give the operator two sources for one truth. +type WorkspaceMsg struct { + // Directory is where the session is rooted. + Directory string + // Repository is the project it belongs to, e.g. "org/name". + Repository string +} + +// EditStatsMsg counts what the session has read and changed. +// +// Also not in the protocol: no event aggregates per-tool line counts today. A +// tool provider knows them, so the natural path is a widget or a kernel-side +// rollup — either way it reaches the shell as this message. +type EditStatsMsg struct { + LinesRead int64 + LinesAdded int64 + LinesRemoved int64 +} diff --git a/internal/tui/shell/focus.go b/internal/tui/shell/focus.go new file mode 100644 index 0000000..647b059 --- /dev/null +++ b/internal/tui/shell/focus.go @@ -0,0 +1,77 @@ +package shell + +// Focus identifies which region owns the keyboard. +// +// Focus targets are regions, not individual nodes: within a focused region an +// action cursor selects among that region's reachable elements. The protocol +// has no concept of focus at all, so this is entirely the shell's model. +type Focus int + +const ( + // FocusInput is the composer. It holds focus at startup, because typing is + // the overwhelmingly common intent and a shell that demands a keystroke + // before accepting text is hostile. + FocusInput Focus = iota + // FocusMain is the conversation transcript. + FocusMain + // FocusSidebar is the widget column. + FocusSidebar +) + +// String returns the region name, used in the hotkey hint line. +func (f Focus) String() string { + switch f { + case FocusInput: + return "input" + case FocusMain: + return "chat" + case FocusSidebar: + return "sidebar" + default: + return "unknown" + } +} + +// focusRing returns the focus targets currently reachable by tab, in cycle +// order. A region that is off screen, or on screen with nothing to interact +// with, is omitted rather than being a dead stop in the cycle. +func focusRing(l Layout, sidebarHasContent bool) []Focus { + ring := []Focus{FocusInput, FocusMain} + if l.ShowSidebar && sidebarHasContent { + ring = append(ring, FocusSidebar) + } + + return ring +} + +// cycleFocus advances focus around the ring. A current focus that is no longer +// in the ring — the sidebar closing while focused, say — resolves to the first +// entry rather than trapping the keyboard in a region that is gone. +func cycleFocus(cur Focus, ring []Focus, back bool) Focus { + if len(ring) == 0 { + return FocusInput + } + + idx := -1 + + for i, f := range ring { + if f == cur { + idx = i + + break + } + } + + if idx < 0 { + return ring[0] + } + + step := 1 + if back { + step = -1 + } + + next := (idx + step + len(ring)) % len(ring) + + return ring[next] +} diff --git a/internal/tui/shell/focus_test.go b/internal/tui/shell/focus_test.go new file mode 100644 index 0000000..b248daf --- /dev/null +++ b/internal/tui/shell/focus_test.go @@ -0,0 +1,108 @@ +package shell + +import "testing" + +func sameFocus(a, b []Focus) bool { + if len(a) != len(b) { + return false + } + + for i := range a { + if a[i] != b[i] { + return false + } + } + + return true +} + +func TestFocusString(t *testing.T) { + t.Parallel() + + tests := map[Focus]string{ + FocusInput: "input", + FocusMain: "chat", + FocusSidebar: "sidebar", + Focus(99): "unknown", + } + + for f, want := range tests { + if got := f.String(); got != want { + t.Errorf("Focus(%d).String() = %q, want %q", f, got, want) + } + } +} + +// A region that is off screen, or on screen with nothing to interact with, is +// omitted rather than being a dead stop in the tab cycle. +func TestFocusRingOmitsUnreachableRegions(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + sidebar bool + hasContent bool + want []Focus + }{ + {"sidebar hidden", false, true, []Focus{FocusInput, FocusMain}}, + {"sidebar shown but empty", true, false, []Focus{FocusInput, FocusMain}}, + {"sidebar shown with content", true, true, []Focus{FocusInput, FocusMain, FocusSidebar}}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + got := focusRing(Layout{ShowSidebar: tc.sidebar}, tc.hasContent) + if !sameFocus(got, tc.want) { + t.Fatalf("focusRing = %v, want %v", got, tc.want) + } + }) + } +} + +func TestCycleFocusWrapsBothWays(t *testing.T) { + t.Parallel() + + ring := []Focus{FocusInput, FocusMain, FocusSidebar} + + forward := []Focus{FocusMain, FocusSidebar, FocusInput} + cur := FocusInput + + for i, want := range forward { + cur = cycleFocus(cur, ring, false) + if cur != want { + t.Fatalf("forward step %d = %v, want %v", i, cur, want) + } + } + + backward := []Focus{FocusSidebar, FocusMain, FocusInput} + cur = FocusInput + + for i, want := range backward { + cur = cycleFocus(cur, ring, true) + if cur != want { + t.Fatalf("backward step %d = %v, want %v", i, cur, want) + } + } +} + +// The sidebar closing while focused must not trap the keyboard in a region +// that is no longer there. +func TestCycleFocusRecoversFromAVanishedRegion(t *testing.T) { + t.Parallel() + + ring := []Focus{FocusInput, FocusMain} + + if got := cycleFocus(FocusSidebar, ring, false); got != FocusInput { + t.Fatalf("cycling from a vanished region = %v, want %v", got, FocusInput) + } +} + +func TestCycleFocusEmptyRing(t *testing.T) { + t.Parallel() + + if got := cycleFocus(FocusMain, nil, false); got != FocusInput { + t.Fatalf("empty ring = %v, want %v", got, FocusInput) + } +} diff --git a/internal/tui/shell/format.go b/internal/tui/shell/format.go new file mode 100644 index 0000000..59f9acd --- /dev/null +++ b/internal/tui/shell/format.go @@ -0,0 +1,132 @@ +package shell + +import ( + "fmt" + "math" + "strconv" + "strings" + "time" +) + +const ( + // minMeterBar is the shortest fill bar worth drawing. Below it the bar says + // less than the percentage printed beside it, so the segment drops the bar + // and keeps the number. + minMeterBar = 8 + // minContextSegment reserves the label, the absolute figures, the + // percentage, and a bar of at least minMeterBar. + // + // Reserving room for the bar rather than just the text is what keeps the + // line stable while a terminal is resized: without it, a right-hand field + // becoming affordable could take the meter from drawable to + // below-the-minimum in a single column, so the bar blinked out and back as + // the window moved. + minContextSegment = 36 +) + +// formatPercent renders a 0..1 fraction as a whole percentage. +func formatPercent(f float64) string { + return strconv.Itoa(int(math.Round(math.Min(math.Max(f, 0), 1)*100))) + "%" +} + +// formatTokens abbreviates a token count: exact below a thousand, then k, then +// M. A status bar has no room for nine digits, and nobody reads them anyway. +func formatTokens(n int64) string { + switch { + case n < 0: + return "0" + case n < 1_000: + return strconv.FormatInt(n, 10) + case n < 1_000_000: + return trimZero(float64(n)/1_000) + "k" + default: + return trimZero(float64(n)/1_000_000) + "M" + } +} + +// trimZero renders one decimal place, dropping it when it is zero, so counts +// read as "18.2k" and "5k" rather than "5.0k". +func trimZero(v float64) string { + s := strconv.FormatFloat(v, 'f', 1, 64) + + return strings.TrimSuffix(s, ".0") +} + +// formatUSD renders a cost. Sub-cent amounts get a third decimal rather than +// rounding to $0.00, which would read as free. +func formatUSD(v float64) string { + if v > 0 && v < 0.01 { + return fmt.Sprintf("$%.3f", v) + } + + return fmt.Sprintf("$%.2f", v) +} + +// formatDuration renders elapsed time at the coarsest useful precision. +func formatDuration(d time.Duration) string { + if d <= 0 { + return "" + } + + d = d.Round(time.Second) + + h := int(d.Hours()) + mn := int(d.Minutes()) % 60 + sec := int(d.Seconds()) % 60 + + if h > 0 { + return fmt.Sprintf("%dh%02dm", h, mn) + } + + if mn > 0 { + return fmt.Sprintf("%dm%02ds", mn, sec) + } + + return strconv.Itoa(sec) + "s" +} + +// summary renders the read/changed line counts, or empty when nothing has been +// touched yet. +func (e EditStatsMsg) summary() string { + if e.LinesRead == 0 && e.LinesAdded == 0 && e.LinesRemoved == 0 { + return "" + } + + return fmt.Sprintf("%s read +%s -%s", + formatTokens(e.LinesRead), formatTokens(e.LinesAdded), formatTokens(e.LinesRemoved)) +} + +// tokenSummary renders the write/cache-write/read split. It is nil-safe +// because usage is absent until the first turn reports. +func (u *UsageMsg) tokenSummary() string { + if u == nil { + return "" + } + + return fmt.Sprintf("%s out %s in %s cw", + formatTokens(u.OutputTokens), formatTokens(u.InputTokens+u.CacheReadTokens), formatTokens(u.CacheWriteTokens)) +} + +// cacheRate is the nil-safe form of UsageMsg.CacheRate. +func (u *UsageMsg) cacheRate() (float64, bool) { + if u == nil { + return 0, false + } + + return u.CacheRate() +} + +// tokens renders one token count from usage, nil-safe, or empty when there is +// no usage yet or the count is zero. +func (u *UsageMsg) tokens(pick func(*UsageMsg) int64) string { + if u == nil { + return "" + } + + n := pick(u) + if n <= 0 { + return "" + } + + return formatTokens(n) +} diff --git a/internal/tui/shell/format_test.go b/internal/tui/shell/format_test.go new file mode 100644 index 0000000..2dbddfa --- /dev/null +++ b/internal/tui/shell/format_test.go @@ -0,0 +1,112 @@ +package shell + +import ( + "strings" + "testing" + "time" +) + +func TestFormatTokens(t *testing.T) { + t.Parallel() + + tests := map[int64]string{ + 0: "0", 42: "42", 999: "999", + 1_000: "1k", 1_240: "1.2k", 18_200: "18.2k", 999_000: "999k", + 1_000_000: "1M", 2_450_000: "2.5M", + -5: "0", + } + + for in, want := range tests { + if got := formatTokens(in); got != want { + t.Errorf("formatTokens(%d) = %q, want %q", in, got, want) + } + } +} + +// A sub-cent cost must not round to $0.00, which reads as free. +func TestFormatUSD(t *testing.T) { + t.Parallel() + + tests := map[float64]string{ + 0: "$0.00", 0.004: "$0.004", 0.42: "$0.42", 12.5: "$12.50", + } + + for in, want := range tests { + if got := formatUSD(in); got != want { + t.Errorf("formatUSD(%v) = %q, want %q", in, got, want) + } + } +} + +func TestFormatDuration(t *testing.T) { + t.Parallel() + + tests := map[time.Duration]string{ + 0: "", + -time.Second: "", + 45 * time.Second: "45s", + 90 * time.Second: "1m30s", + 22 * time.Minute: "22m00s", + 2*time.Hour + 5*time.Minute: "2h05m", + } + + for in, want := range tests { + if got := formatDuration(in); got != want { + t.Errorf("formatDuration(%v) = %q, want %q", in, got, want) + } + } +} + +func TestFormatPercent(t *testing.T) { + t.Parallel() + + tests := map[float64]string{0: "0%", 0.256: "26%", 1: "100%", -1: "0%", 5: "100%"} + + for in, want := range tests { + if got := formatPercent(in); got != want { + t.Errorf("formatPercent(%v) = %q, want %q", in, got, want) + } + } +} + +// Cache reads are never also counted as input tokens, so the rate is a real +// ratio rather than a double count. +func TestCacheRate(t *testing.T) { + t.Parallel() + + u := UsageMsg{InputTokens: 100, CacheReadTokens: 900} + + got, ok := u.CacheRate() + if !ok || got != 0.9 { + t.Fatalf("CacheRate() = (%v, %v), want (0.9, true)", got, ok) + } + + if _, ok := (UsageMsg{}).CacheRate(); ok { + t.Error("CacheRate claimed to know a rate with no tokens") + } + + // Nil-safe: usage is absent until the first turn reports. + var absent *UsageMsg + if _, ok := absent.cacheRate(); ok { + t.Error("nil usage reported a cache rate") + } + + if got := absent.tokenSummary(); got != "" { + t.Errorf("nil usage token summary = %q", got) + } +} + +func TestEditStatsSummary(t *testing.T) { + t.Parallel() + + if got := (EditStatsMsg{}).summary(); got != "" { + t.Errorf("untouched summary = %q, want empty", got) + } + + got := EditStatsMsg{LinesRead: 4820, LinesAdded: 612, LinesRemoved: 148}.summary() + for _, want := range []string{"4.8k", "+612", "-148"} { + if !strings.Contains(got, want) { + t.Errorf("summary %q missing %q", got, want) + } + } +} diff --git a/internal/tui/shell/input.go b/internal/tui/shell/input.go new file mode 100644 index 0000000..86c877d --- /dev/null +++ b/internal/tui/shell/input.go @@ -0,0 +1,178 @@ +package shell + +import "strings" + +// input is the composer's editable buffer. +// +// It is deliberately small rather than a full editor: enough to type, correct, +// and recall a prompt, with no dependency on a terminal so every editing rule +// is unit-testable. Text is held as runes so cursor motion is +// grapheme-approximate rather than byte-indexed, which is what keeps multi-byte +// input from corrupting on backspace. +type input struct { + text []rune + cursor int + + history []string + // histIdx walks history from the end; len(history) means "not browsing", + // which is the state a fresh keystroke always returns to. + histIdx int + // draft preserves what was typed before history browsing started. + draft string +} + +func newInput() *input { return &input{} } + +// Value returns the current buffer contents. +func (i *input) Value() string { return string(i.text) } + +// Lines reports how many display lines the buffer needs. +func (i *input) Lines() int { return strings.Count(string(i.text), "\n") + 1 } + +// Insert adds runes at the cursor. +func (i *input) Insert(rs ...string) { + for _, s := range rs { + for _, r := range s { + i.text = append(i.text, 0) + copy(i.text[i.cursor+1:], i.text[i.cursor:]) + i.text[i.cursor] = r + i.cursor++ + } + } + + i.stopBrowsing() +} + +// Backspace deletes the rune before the cursor. +func (i *input) Backspace() { + if i.cursor == 0 { + return + } + + i.text = append(i.text[:i.cursor-1], i.text[i.cursor:]...) + i.cursor-- + i.stopBrowsing() +} + +// Left moves the cursor one rune left. +func (i *input) Left() { + if i.cursor > 0 { + i.cursor-- + } +} + +// Right moves the cursor one rune right. +func (i *input) Right() { + if i.cursor < len(i.text) { + i.cursor++ + } +} + +// Home moves the cursor to the start of the buffer. +func (i *input) Home() { i.cursor = 0 } + +// End moves the cursor to the end of the buffer. +func (i *input) End() { i.cursor = len(i.text) } + +// Empty reports whether the buffer has no content. +func (i *input) Empty() bool { return len(i.text) == 0 } + +// OnFirstLine reports whether the cursor sits on the buffer's first line, +// which is what makes "up" mean history rather than cursor motion. +func (i *input) OnFirstLine() bool { + return !strings.Contains(string(i.text[:i.cursor]), "\n") +} + +// OnLastLine reports whether the cursor sits on the buffer's last line. +func (i *input) OnLastLine() bool { + return !strings.Contains(string(i.text[i.cursor:]), "\n") +} + +// Submit returns the buffer, records it in history, and clears the composer. +// An all-whitespace buffer returns ok false and is neither sent nor recorded. +func (i *input) Submit() (string, bool) { + v := strings.TrimSpace(string(i.text)) + if v == "" { + return "", false + } + + i.history = append(i.history, v) + i.text = nil + i.cursor = 0 + i.draft = "" + i.histIdx = len(i.history) + + return v, true +} + +// HistoryPrev recalls the previous entry, preserving the in-progress draft on +// the first step back so browsing away and back is non-destructive. +func (i *input) HistoryPrev() { + if len(i.history) == 0 || i.histIdx == 0 { + return + } + + if i.histIdx == len(i.history) { + i.draft = string(i.text) + } + + i.histIdx-- + i.set(i.history[i.histIdx]) +} + +// HistoryNext walks forward, restoring the preserved draft past the newest +// entry. +func (i *input) HistoryNext() { + if i.histIdx >= len(i.history) { + return + } + + i.histIdx++ + if i.histIdx == len(i.history) { + i.set(i.draft) + + return + } + + i.set(i.history[i.histIdx]) +} + +func (i *input) set(s string) { + i.text = []rune(s) + i.cursor = len(i.text) +} + +// stopBrowsing returns the buffer to "editing a draft" state, so a keystroke +// during history browsing keeps what is on screen instead of snapping back. +func (i *input) stopBrowsing() { i.histIdx = len(i.history) } + +// CursorPos reports the cursor's line and column within the buffer, both +// zero-based. +// +// The shell turns this into an absolute screen position and hands it to Bubble +// Tea as the real terminal cursor, which is why the buffer renders no caret +// glyph of its own: a drawn caret and a real cursor would both be visible. +func (i *input) CursorPos() (line, col int) { + for _, r := range i.text[:i.cursor] { + if r == '\n' { + line++ + col = 0 + + continue + } + + col++ + } + + return line, col +} + +// render draws the buffer. Placeholder text stands in when the buffer is empty +// and the composer has focus, so the pane never looks broken. +func (i *input) render(placeholder string) string { + if i.Empty() { + return placeholder + } + + return string(i.text) +} diff --git a/internal/tui/shell/input_test.go b/internal/tui/shell/input_test.go new file mode 100644 index 0000000..5ae4765 --- /dev/null +++ b/internal/tui/shell/input_test.go @@ -0,0 +1,323 @@ +package shell + +import "testing" + +func TestInsertAndBackspace(t *testing.T) { + t.Parallel() + + in := newInput() + in.Insert("h", "i") + + if got := in.Value(); got != "hi" { + t.Fatalf("Value = %q, want %q", got, "hi") + } + + in.Backspace() + + if got := in.Value(); got != "h" { + t.Fatalf("after backspace = %q, want %q", got, "h") + } + + in.Backspace() + in.Backspace() // past the start is a no-op, not a panic + + if !in.Empty() { + t.Fatalf("expected empty buffer, got %q", in.Value()) + } +} + +// Multi-byte input must not corrupt on backspace, which is why the buffer is +// held as runes rather than bytes. +func TestMultiByteEditing(t *testing.T) { + t.Parallel() + + in := newInput() + in.Insert("héllo→") + in.Backspace() + + if got := in.Value(); got != "héllo" { + t.Fatalf("multi-byte backspace corrupted the buffer: %q", got) + } +} + +func TestCursorMovementAndMidBufferInsert(t *testing.T) { + t.Parallel() + + in := newInput() + in.Insert("ac") + in.Left() + in.Insert("b") + + if got := in.Value(); got != "abc" { + t.Fatalf("mid-buffer insert = %q, want %q", got, "abc") + } + + in.Home() + in.Insert(">") + + if got := in.Value(); got != ">abc" { + t.Fatalf("insert at home = %q, want %q", got, ">abc") + } + + in.End() + in.Insert("<") + + if got := in.Value(); got != ">abc<" { + t.Fatalf("insert at end = %q, want %q", got, ">abc<") + } + + // Motion past either boundary is a no-op. + for range 10 { + in.Left() + } + + in.Left() + + for range 20 { + in.Right() + } + + in.Insert("!") + + if got := in.Value(); got != ">abc 0 { + label = b.Keys[0] + } + + return label + " " + b.Help +} + +// KeyMap is the shell's complete binding set. +// +// There is no protocol-level keybinding registration, so a widget cannot claim +// a key. Widgets expose affordances as ActionNodes and reach the keyboard +// through the action cursor instead. That is a deliberate limitation: it keeps +// this map total and conflict-free, at the cost of widgets not binding +// accelerators of their own. +type KeyMap struct { + // Global. + Interrupt Binding + Quit Binding + NextFocus Binding + PrevFocus Binding + CycleAgent Binding + ToggleSidebar Binding + + // main_chat and sidebar. + Up Binding + Down Binding + PageUp Binding + PageDown Binding + Top Binding + Bottom Binding + Activate Binding + + // input_bar. + Submit Binding + Newline Binding + HistoryPrev Binding + HistoryNext Binding + + // overlay. + Allow Binding + Deny Binding + AllowSession Binding + Edit Binding + Dismiss Binding +} + +// DefaultKeyMap returns the shell's built-in bindings. +func DefaultKeyMap() KeyMap { + return KeyMap{ + Interrupt: Binding{Keys: []string{"ctrl+c"}, Help: "interrupt"}, + Quit: Binding{Keys: []string{"ctrl+d"}, Help: "quit"}, + NextFocus: Binding{Keys: []string{"tab"}, Help: "focus"}, + // shift+tab belongs to the agent switcher, which is the convention + // operators arrive with. The focus ring is at most three entries, so + // cycling forward reaches everything and a backward binding buys + // nothing worth the key. PrevFocus keeps its field so a future + // configuration can bind it. + PrevFocus: Binding{}, + CycleAgent: Binding{Keys: []string{"shift+tab"}, Help: "agent"}, + ToggleSidebar: Binding{Keys: []string{"ctrl+b"}, Help: "sidebar"}, + + Up: Binding{Keys: []string{"up", "k"}, Label: "↑", Help: "up"}, + Down: Binding{Keys: []string{"down", "j"}, Label: "↓", Help: "down"}, + PageUp: Binding{Keys: []string{"pgup"}, Help: "page up"}, + PageDown: Binding{Keys: []string{"pgdown", "pgdn"}, Label: "pgdn", Help: "page down"}, + Top: Binding{Keys: []string{"home"}, Help: "top"}, + Bottom: Binding{Keys: []string{"end"}, Help: "live"}, + Activate: Binding{Keys: []string{"enter"}, Help: "activate"}, + + Submit: Binding{Keys: []string{"enter"}, Help: "send"}, + // shift+enter is the binding operators expect, but a bare terminal + // cannot distinguish it from enter — both are CR. Bubble Tea requests + // key disambiguation (Kitty keyboard / modifyOtherKeys) at startup, + // which makes it available on terminals that support the negotiation. + // alt+enter and ctrl+j are kept as fallbacks for those that do not: + // ctrl+j is literally line feed and works everywhere. + Newline: Binding{ + Keys: []string{"shift+enter", "alt+enter", "ctrl+j"}, + Label: "shift+enter", + Help: "newline", + }, + HistoryPrev: Binding{Keys: []string{"up"}, Label: "↑", Help: "history"}, + HistoryNext: Binding{Keys: []string{"down"}, Label: "↓", Help: "history"}, + + Allow: Binding{Keys: []string{"y"}, Help: "allow"}, + Deny: Binding{Keys: []string{"n"}, Help: "deny"}, + AllowSession: Binding{Keys: []string{"a"}, Help: "allow session"}, + Edit: Binding{Keys: []string{"e"}, Help: "edit args"}, + Dismiss: Binding{Keys: []string{"esc"}, Help: "dismiss"}, + } +} + +// Hints returns the bindings the hotkey hint line should advertise for the +// currently active layer, dropping whole bindings from the end until they fit +// in width. This is what makes hotkey_hints meaningful rather than a static +// legend: it always describes the keyboard as it is right now. +// +// Whole bindings go rather than the string being cut, because a hint truncated +// mid-word ("ctrl+c inter") is worse than an absent one — it looks like a +// rendering fault and tells the operator nothing. +func (k KeyMap) Hints(layer Layer, focus Focus, sidebarAvailable bool, width int) string { + var bindings []Binding + + switch layer { + case LayerOverlay: + bindings = []Binding{k.Allow, k.Deny, k.AllowSession, k.Edit, k.Dismiss} + case LayerRegion, LayerGlobal: + bindings = k.regionHints(focus, sidebarAvailable) + } + + parts := make([]string, 0, len(bindings)) + for _, b := range bindings { + parts = append(parts, b.hint()) + } + + for len(parts) > 1 && lipgloss.Width(strings.Join(parts, hintSeparator)) > width { + parts = parts[:len(parts)-1] + } + + return strings.Join(parts, hintSeparator) +} + +// hintSeparator divides adjacent key hints. +const hintSeparator = " · " + +func (k KeyMap) regionHints(focus Focus, sidebarAvailable bool) []Binding { + var bindings []Binding + + switch focus { + case FocusInput: + bindings = []Binding{k.Submit, k.Newline, k.CycleAgent, k.NextFocus} + case FocusMain, FocusSidebar: + bindings = []Binding{k.Up, k.Down, k.Activate, k.Bottom, k.CycleAgent, k.NextFocus} + } + + if sidebarAvailable { + bindings = append(bindings, k.ToggleSidebar) + } + + return append(bindings, k.Interrupt) +} diff --git a/internal/tui/shell/keymap_test.go b/internal/tui/shell/keymap_test.go new file mode 100644 index 0000000..9e13519 --- /dev/null +++ b/internal/tui/shell/keymap_test.go @@ -0,0 +1,147 @@ +package shell + +import ( + "strings" + "testing" +) + +func TestBindingMatches(t *testing.T) { + t.Parallel() + + b := Binding{Keys: []string{"pgdown", "pgdn"}} + + for _, k := range []string{"pgdown", "pgdn"} { + if !b.Matches(k) { + t.Errorf("Matches(%q) = false, want true", k) + } + } + + if b.Matches("pgup") { + t.Error("Matches(pgup) = true, want false") + } + + if (Binding{}).Matches("anything") { + t.Error("an empty binding matched a key") + } +} + +func TestBindingHintUsesLabelThenFirstKey(t *testing.T) { + t.Parallel() + + labeled := Binding{Keys: []string{"down"}, Label: "↓", Help: "scroll"} + if got, want := labeled.hint(), "↓ scroll"; got != want { + t.Errorf("hint = %q, want %q", got, want) + } + + unlabeled := Binding{Keys: []string{"tab"}, Help: "focus"} + if got, want := unlabeled.hint(), "tab focus"; got != want { + t.Errorf("hint = %q, want %q", got, want) + } +} + +// The default map must not bind one key to two things inside a single layer, +// which is what makes the layered precedence total and conflict-free. +func TestDefaultKeyMapHasNoIntraLayerConflicts(t *testing.T) { + t.Parallel() + + k := DefaultKeyMap() + + layers := map[string][]Binding{ + "global": {k.Interrupt, k.Quit, k.NextFocus, k.PrevFocus, k.CycleAgent, k.ToggleSidebar}, + "content": {k.Up, k.Down, k.PageUp, k.PageDown, k.Top, k.Bottom, k.Activate}, + "input": {k.Submit, k.Newline}, + "overlay": {k.Allow, k.Deny, k.AllowSession, k.Edit, k.Dismiss}, + } + + for name, bindings := range layers { + seen := map[string]bool{} + + for _, b := range bindings { + for _, key := range b.Keys { + if seen[key] { + t.Errorf("layer %q binds %q twice", name, key) + } + + seen[key] = true + } + } + } +} + +func TestHintsDescribeTheActiveLayer(t *testing.T) { + t.Parallel() + + k := DefaultKeyMap() + + overlay := k.Hints(LayerOverlay, FocusInput, true, 200) + for _, want := range []string{"allow", "deny", "edit args"} { + if !strings.Contains(overlay, want) { + t.Errorf("overlay hints missing %q: %q", want, overlay) + } + } + + // The overlay layer captures the keyboard, so region bindings must not be + // advertised while it is up. + if strings.Contains(overlay, "send") { + t.Errorf("overlay hints leaked an input binding: %q", overlay) + } + + input := k.Hints(LayerRegion, FocusInput, true, 200) + if !strings.Contains(input, "send") { + t.Errorf("input hints missing send: %q", input) + } + + chat := k.Hints(LayerRegion, FocusMain, true, 200) + if !strings.Contains(chat, "activate") { + t.Errorf("chat hints missing activate: %q", chat) + } + + if strings.Contains(chat, "send") { + t.Errorf("chat hints advertised the input binding: %q", chat) + } +} + +// The sidebar toggle is only advertised where it can actually do something. +// Whole bindings drop from the end rather than the line being cut mid-word. +func TestHintsDropWholeBindingsToFit(t *testing.T) { + t.Parallel() + + k := DefaultKeyMap() + + full := k.Hints(LayerRegion, FocusInput, true, 200) + short := k.Hints(LayerRegion, FocusInput, true, 40) + + if len(short) >= len(full) { + t.Fatalf("narrow hints were not shortened: %q", short) + } + + if strings.HasSuffix(short, " ") || strings.Contains(short, " · \u0000") { + t.Errorf("hints end raggedly: %q", short) + } + + // What survives must be complete bindings, never a fragment. + for _, part := range strings.Split(short, " · ") { + if part == "" { + t.Errorf("empty hint fragment in %q", short) + } + } + + // At least one binding always survives, however little room there is. + if k.Hints(LayerRegion, FocusInput, true, 1) == "" { + t.Error("all hints dropped; at least the first should survive") + } +} + +func TestHintsOmitSidebarToggleWhenUnavailable(t *testing.T) { + t.Parallel() + + k := DefaultKeyMap() + + if got := k.Hints(LayerRegion, FocusInput, false, 200); strings.Contains(got, "sidebar") { + t.Errorf("hints advertised the sidebar toggle on a too-narrow terminal: %q", got) + } + + if got := k.Hints(LayerRegion, FocusInput, true, 200); !strings.Contains(got, "sidebar") { + t.Errorf("hints omitted an available sidebar toggle: %q", got) + } +} diff --git a/internal/tui/shell/layout.go b/internal/tui/shell/layout.go new file mode 100644 index 0000000..41f4706 --- /dev/null +++ b/internal/tui/shell/layout.go @@ -0,0 +1,186 @@ +package shell + +import "github.com/pluggableharness/agent/internal/tui/theme" + +// Layout geometry constants. These are the breakpoints documented in +// docs/first-party/frontends/tui.md; changing one here means changing it there. +const ( + sidebarMinWidth = 26 + sidebarMaxWidth = 38 + sidebarBreakpoint = 100 + sidebarFloorWidth = 64 + + // The header and footer are bordered boxes rather than single lines, which + // costs two rows each in chrome. They are dropped outright on a short + // terminal rather than degrading to an unboxed line: one visual language is + // worth more than one extra row of transcript. + chromePanelHeight = 3 + + hintsMinHeight = 16 + topBarMinHeight = 12 + + // statusMinHeight is where the volatile-state line fits beneath the + // composer. It is a single row: static session data lives in the sidebar, + // which has room for it, so the space beside the composer is spent only on + // what actually changes during a turn. + statusMinHeight = 9 + + inputMinHeight = 1 + inputMaxHeight = 6 + + // panelChrome is the rows and columns a panel spends on its own border. + panelChrome = 2 + // panelPadding is the horizontal padding inside a panel, per side. + panelPadding = theme.Space1 +) + +// Layout is the solved geometry for one frame: which regions are on screen and +// the outer box each one occupies. +// +// Every dimension here is an *outer* size, borders included. Interior content +// sizes come from the Inner helpers, so a caller never open-codes the chrome +// arithmetic and panes can never disagree about how wide their content is. +// +// Regions are dropped in a fixed order as space runs out — sidebar, then hotkey +// hints, then top bar. main_chat and input_bar are never dropped: a shell that +// can show neither input nor output is not a shell. +type Layout struct { + Width int + Height int + + ShowTopBar bool + ShowHints bool + ShowSidebar bool + + // SidebarAvailable reports whether the terminal is wide enough for the + // sidebar to be toggled on at all. Below the floor width its content folds + // into main_chat instead, per the protocol's allowance that a frontend may + // reinterpret placement for its own layout. + SidebarAvailable bool + + // Outer panel boxes. + MainWidth int + SidebarWidth int + BodyHeight int + + // InputHeight is the composer's content lines; ComposerHeight is its outer + // box, chrome included. + InputHeight int + ComposerHeight int + + // ShowStatus is whether the volatile-state line fits beneath the composer. + ShowStatus bool +} + +// FoldSidebar reports whether sidebar content should be folded into main_chat. +// True only when the terminal is too narrow for the sidebar to exist as a pane +// at all — a merely-toggled-off sidebar keeps its content, reachable by +// toggling it back on. +func (l Layout) FoldSidebar() bool { return !l.SidebarAvailable } + +// MainInnerWidth is the content width inside the main panel. +func (l Layout) MainInnerWidth() int { return max(l.MainWidth-panelChrome-2*panelPadding, 1) } + +// MainInnerHeight is the content height inside the main panel. +func (l Layout) MainInnerHeight() int { return max(l.BodyHeight-panelChrome, 1) } + +// SidebarInnerWidth is the content width inside a sidebar panel. +func (l Layout) SidebarInnerWidth() int { return max(l.SidebarWidth-panelChrome-2*panelPadding, 1) } + +// StatusInset is the margin either side of the status line. +// +// It is two cells rather than the panels' one because the status line has no +// border of its own: a panel spends a gutter, a border, and a pad before its +// text starts, so an unboxed line needs the same total to sit on the same +// column. Without it the status line starts two columns left of everything +// above and below it and reads as slightly loose. +const StatusInset = theme.Space2 + +// StatusWidth is the width the status line renders into, inset either side so +// its text aligns with the content inside the panels around it. +func (l Layout) StatusWidth() int { return max(l.Width-2*StatusInset, 1) } + +// ComposerTop is the row the composer's top border occupies. +// +// The frame stacks its bands in this order, and the cursor has to be placed +// against the same arithmetic. Deriving it here rather than recomputing it at +// the point of use is what stops the two from drifting apart — they already did +// once, when the header grew from a single line into a bordered box and the +// cursor kept counting it as one row. +func (l Layout) ComposerTop() int { + top := l.BodyHeight + if l.ShowTopBar { + top += chromePanelHeight + } + + return top +} + +// ChromeInnerWidth is the content width inside a header or footer panel. +func (l Layout) ChromeInnerWidth() int { + return max(l.Width-2*theme.Gutter-panelChrome-2*panelPadding, 1) +} + +// ComposerInnerWidth is the content width inside the composer panel. +func (l Layout) ComposerInnerWidth() int { + return max(l.Width-2*theme.Gutter-panelChrome-2*panelPadding, 1) +} + +// Solve computes the layout for a terminal of the given size. +// +// inputLines is the number of lines the composer currently needs, and +// sidebarOpen is the operator's toggle state, which only matters between the +// floor and the breakpoint — above the breakpoint the sidebar is always shown, +// below the floor it is never available. +func Solve(width, height, inputLines int, sidebarOpen bool) Layout { + l := Layout{Width: width, Height: height} + + l.InputHeight = clamp(inputLines, inputMinHeight, inputMaxHeight) + l.ComposerHeight = l.InputHeight + panelChrome + l.ShowHints = height >= hintsMinHeight + l.ShowTopBar = height >= topBarMinHeight + + l.SidebarAvailable = width >= sidebarFloorWidth + switch { + case width >= sidebarBreakpoint: + l.ShowSidebar = true + case l.SidebarAvailable: + l.ShowSidebar = sidebarOpen + default: + l.ShowSidebar = false + } + + // Horizontal: a gutter at each screen edge, and a gap between the main + // panel and the sidebar column when both are present. + bodyWidth := max(width-2*theme.Gutter, 1) + + if l.ShowSidebar { + l.SidebarWidth = min(clamp(width*3/10, sidebarMinWidth, sidebarMaxWidth), width*2/5) + l.MainWidth = max(bodyWidth-l.SidebarWidth-theme.Space1, 1) + } else { + l.MainWidth = bodyWidth + } + + l.ShowStatus = height >= statusMinHeight + + used := l.ComposerHeight + if l.ShowStatus { + used++ + } + + // The header and footer are boxes, not lines: each costs its border rows + // as well as its content row. + if l.ShowTopBar { + used += chromePanelHeight + } + + if l.ShowHints { + used += chromePanelHeight + } + + l.BodyHeight = max(height-used, 1) + + return l +} + +func clamp(v, lo, hi int) int { return min(max(v, lo), hi) } diff --git a/internal/tui/shell/layout_test.go b/internal/tui/shell/layout_test.go new file mode 100644 index 0000000..9343f64 --- /dev/null +++ b/internal/tui/shell/layout_test.go @@ -0,0 +1,218 @@ +package shell + +import ( + "testing" + + "github.com/pluggableharness/agent/internal/tui/theme" +) + +func TestSolveWideTerminalShowsEveryRegion(t *testing.T) { + t.Parallel() + + l := Solve(120, 40, 1, false) + + if !l.ShowTopBar || !l.ShowHints || !l.ShowSidebar { + t.Fatalf("wide terminal dropped a region: %+v", l) + } + + if l.SidebarWidth < sidebarMinWidth || l.SidebarWidth > sidebarMaxWidth { + t.Errorf("sidebar width %d outside [%d,%d]", l.SidebarWidth, sidebarMinWidth, sidebarMaxWidth) + } + + // Panels plus both gutters plus the gap between them fill the terminal + // exactly; a mismatch would leave an uncovered column. + if got := l.MainWidth + l.SidebarWidth + theme.Space1 + 2*theme.Gutter; got != l.Width { + t.Errorf("widths do not sum to the terminal: %d != %d", got, l.Width) + } + + // The header and footer boxes, the status line, and the composer's outer + // box all take rows out of the body. + chrome := 2*chromePanelHeight + 1 + l.ComposerHeight + + if want := 40 - chrome; l.BodyHeight != want { + t.Errorf("BodyHeight = %d, want %d", l.BodyHeight, want) + } +} + +// Interior sizes must account for border and padding, so no caller open-codes +// the chrome arithmetic. +func TestInnerSizesSubtractChrome(t *testing.T) { + t.Parallel() + + l := Solve(120, 40, 1, false) + + if got, want := l.MainInnerWidth(), l.MainWidth-panelChrome-2*panelPadding; got != want { + t.Errorf("MainInnerWidth() = %d, want %d", got, want) + } + + if got, want := l.MainInnerHeight(), l.BodyHeight-panelChrome; got != want { + t.Errorf("MainInnerHeight() = %d, want %d", got, want) + } + + if l.ComposerHeight != l.InputHeight+panelChrome { + t.Errorf("ComposerHeight = %d, want InputHeight+%d", l.ComposerHeight, panelChrome) + } +} + +// Above the breakpoint the sidebar is always shown; between the floor and the +// breakpoint it follows the operator's toggle; below the floor it is gone. +func TestSidebarBreakpoints(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + width int + open bool + wantShown bool + wantAvailable bool + }{ + {"wide ignores toggle off", 120, false, true, true}, + {"wide ignores toggle on", 120, true, true, true}, + {"medium closed", 80, false, false, true}, + {"medium opened", 80, true, true, true}, + {"at breakpoint", sidebarBreakpoint, false, true, true}, + {"just below breakpoint", sidebarBreakpoint - 1, false, false, true}, + {"at floor", sidebarFloorWidth, true, true, true}, + {"below floor cannot open", sidebarFloorWidth - 1, true, false, false}, + {"very narrow", 30, true, false, false}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + l := Solve(tc.width, 40, 1, tc.open) + + if l.ShowSidebar != tc.wantShown { + t.Errorf("ShowSidebar = %v, want %v", l.ShowSidebar, tc.wantShown) + } + + if l.SidebarAvailable != tc.wantAvailable { + t.Errorf("SidebarAvailable = %v, want %v", l.SidebarAvailable, tc.wantAvailable) + } + + // Folding is exactly the case where the sidebar cannot exist as a + // pane, not merely the case where it is toggled off. + if got, want := l.FoldSidebar(), !tc.wantAvailable; got != want { + t.Errorf("FoldSidebar() = %v, want %v", got, want) + } + }) + } +} + +func TestSidebarNeverExceedsFortyPercent(t *testing.T) { + t.Parallel() + + for w := sidebarBreakpoint; w <= 400; w += 7 { + l := Solve(w, 40, 1, true) + if l.SidebarWidth > w*2/5 { + t.Fatalf("width %d: sidebar %d exceeds 40%% (%d)", w, l.SidebarWidth, w*2/5) + } + } +} + +// Regions are dropped in a fixed order as height runs out: hints first, then +// the top bar. main_chat and input_bar are never dropped. +func TestHeightDegradationOrder(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + height int + wantTopBar bool + wantHints bool + }{ + {"roomy", 40, true, true}, + {"at hints minimum", hintsMinHeight, true, true}, + {"below hints minimum", hintsMinHeight - 1, true, false}, + {"at top bar minimum", topBarMinHeight, true, false}, + {"below top bar minimum", topBarMinHeight - 1, false, false}, + {"pathological", 1, false, false}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + l := Solve(120, tc.height, 1, false) + + if l.ShowTopBar != tc.wantTopBar { + t.Errorf("ShowTopBar = %v, want %v", l.ShowTopBar, tc.wantTopBar) + } + + if l.ShowHints != tc.wantHints { + t.Errorf("ShowHints = %v, want %v", l.ShowHints, tc.wantHints) + } + + // The two load-bearing regions always survive. + if l.BodyHeight < 1 { + t.Errorf("BodyHeight = %d, want at least 1", l.BodyHeight) + } + + if l.InputHeight < inputMinHeight { + t.Errorf("InputHeight = %d, want at least %d", l.InputHeight, inputMinHeight) + } + }) + } +} + +// The status line is a single row of volatile state and goes when height runs +// short — static session data lives in the sidebar, so nothing is lost with it. +func TestStatusLineDegradesByHeight(t *testing.T) { + t.Parallel() + + if !Solve(120, 40, 1, false).ShowStatus { + t.Error("no status line on a tall terminal") + } + + if !Solve(120, statusMinHeight, 1, false).ShowStatus { + t.Error("status line missing at its threshold height") + } + + if Solve(120, statusMinHeight-1, 1, false).ShowStatus { + t.Error("status line survived below its threshold") + } +} + +// However many rows the chrome takes, the body never collapses. +func TestBodySurvivesEveryHeight(t *testing.T) { + t.Parallel() + + for h := 1; h <= 60; h++ { + l := Solve(120, h, 1, false) + if l.BodyHeight < 1 { + t.Fatalf("height %d gave BodyHeight %d", h, l.BodyHeight) + } + } +} + +func TestInputHeightIsClamped(t *testing.T) { + t.Parallel() + + tests := []struct { + lines int + want int + }{ + {0, inputMinHeight}, + {1, 1}, + {3, 3}, + {inputMaxHeight, inputMaxHeight}, + {inputMaxHeight + 10, inputMaxHeight}, + } + + for _, tc := range tests { + if got := Solve(120, 40, tc.lines, false).InputHeight; got != tc.want { + t.Errorf("Solve(lines=%d).InputHeight = %d, want %d", tc.lines, got, tc.want) + } + } +} + +func TestMainWidthNeverCollapsesBelowOne(t *testing.T) { + t.Parallel() + + for _, w := range []int{0, 1, 5, 30} { + if got := Solve(w, 24, 1, true).MainWidth; got < 1 { + t.Errorf("width %d gave MainWidth %d", w, got) + } + } +} diff --git a/internal/tui/shell/model.go b/internal/tui/shell/model.go new file mode 100644 index 0000000..77aab8c --- /dev/null +++ b/internal/tui/shell/model.go @@ -0,0 +1,1275 @@ +package shell + +import ( + "image/color" + "strconv" + "strings" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + + "github.com/pluggableharness/agent/internal/tui/paint" + "github.com/pluggableharness/agent/internal/tui/region" + "github.com/pluggableharness/agent/internal/tui/theme" + "github.com/pluggableharness/agent/internal/tui/ui" + renderv1 "github.com/pluggableharness/agent/pkg/render/proto/v1" +) + +// overlay is the modal prompt currently capturing the keyboard. +type overlay struct { + title string + itemID string + preview *renderv1.RenderTree + rawInput string + // restore is the focus to return to once the overlay clears. + restore Focus +} + +// Model is the shell's Bubble Tea model: layout, focus, keymap, and the +// composition of every region into one frame. +// +// The model is driven entirely by messages and never performs I/O, which is +// what lets it be tested through tea.WithoutRenderer or by calling Update +// directly with no terminal at all. +type Model struct { + th theme.Theme + painter *paint.Painter + keys KeyMap + store *region.Store + emit func(Action) + + width int + height int + layout Layout + + focus Focus + cursor int + expanded map[string]bool + input *input + scroll int + pinned bool + // maxScroll is the deepest scroll offset the last painted frame allowed. + // It is what lets scrolling back down to the bottom re-attach to the live + // tail instead of leaving the transcript one notch short of it forever. + maxScroll int + sidebarOpen bool + + agents agentRing + + overlay *overlay + notice *NoticeMsg + status StatusMsg + // usage is nil until the first UsageMsg arrives. Nil means "not known + // yet", which must render as absent rather than as zero — a gauge reading + // 0% before any turn has run is a confident lie. + usage *UsageMsg + workspace WorkspaceMsg + edits EditStatsMsg + + // interruptArmed tracks the first ctrl+c of the two-press quit sequence. + interruptArmed bool + quitting bool +} + +// Option configures a Model. +type Option func(*Model) + +// WithTheme sets the theme. The default is theme.Dark. +func WithTheme(t theme.Theme) Option { + return func(m *Model) { + m.th = t + m.painter = paint.New(t) + } +} + +// WithAgents replaces the selectable agent roster. An empty roster keeps the +// defaults, since the shell always needs something to display as active. +func WithAgents(agents []Agent) Option { + return func(m *Model) { m.agents = newAgentRing(agents) } +} + +// WithKeyMap replaces the default bindings. +func WithKeyMap(k KeyMap) Option { return func(m *Model) { m.keys = k } } + +// WithEmitter sets the sink for operator-originated actions. Without one the +// shell still runs and paints; it simply has nowhere to send, which is the +// right behavior for a rendering-only test. +func WithEmitter(f func(Action)) Option { return func(m *Model) { m.emit = f } } + +// New returns a Model ready to receive messages. +func New(opts ...Option) *Model { + m := &Model{ + th: theme.Dark(), + keys: DefaultKeyMap(), + store: region.NewStore(), + expanded: map[string]bool{}, + input: newInput(), + agents: newAgentRing(nil), + focus: FocusInput, + pinned: true, + width: 80, + height: 24, + } + m.painter = paint.New(m.th) + + for _, o := range opts { + o(m) + } + + m.relayout() + + return m +} + +// Init implements tea.Model. The event source runs outside the program, so +// there is no startup command. +func (m *Model) Init() tea.Cmd { return nil } + +// Store exposes the content store so a bridge can inspect what is placed. +func (m *Model) Store() *region.Store { return m.store } + +// Focus reports which region currently owns the keyboard. +func (m *Model) Focus() Focus { return m.focus } + +// Layout reports the geometry of the most recent frame. +func (m *Model) Layout() Layout { return m.layout } + +func (m *Model) relayout() { + m.layout = Solve(m.width, m.height, m.input.Lines(), m.sidebarOpen) +} + +func (m *Model) send(a Action) { + if m.emit != nil { + m.emit(a) + } +} + +// Update implements tea.Model. +func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width, m.height = msg.Width, msg.Height + m.relayout() + case tea.KeyPressMsg: + return m, m.handleKey(msg.String()) + case tea.MouseWheelMsg: + m.handleWheel(msg) + case PlaceMsg: + m.store.Place(msg.Content, msg.Producer, msg.Sequence) + m.store.ClearProducerStreams() + m.relayout() + case DeltaMsg: + m.store.Delta(msg.TargetID, msg.Text) + case SettledMsg: + m.store.ClearStream(msg.TargetID) + case PermissionMsg: + m.openOverlay(msg) + case DismissOverlayMsg: + m.closeOverlay() + m.notice = &NoticeMsg{Text: msg.Reason, Level: NoticeWarn} + case NoticeMsg: + m.notice = &msg + case StatusMsg: + m.status = msg + case UsageMsg: + m.usage = &msg + case WorkspaceMsg: + m.workspace = msg + case EditStatsMsg: + m.edits = msg + } + + return m, nil +} + +// wheelStep is how many transcript lines one wheel notch moves. +const wheelStep = 3 + +// handleWheel scrolls the transcript. +// +// Claiming the wheel is what stops the terminal from scrolling its own +// scrollback out from under a full-screen application — without it the gesture +// appears to work while actually moving the window behind the app. It applies +// regardless of which region holds the keyboard, because pointing at something +// and turning the wheel is not a focus operation. +func (m *Model) handleWheel(msg tea.MouseWheelMsg) { + switch msg.Button { + case tea.MouseWheelUp: + m.scrollBy(-wheelStep) + case tea.MouseWheelDown: + m.scrollBy(wheelStep) + default: + // Horizontal wheel events have nothing to act on. + } +} + +func (m *Model) openOverlay(msg PermissionMsg) { + m.overlay = &overlay{ + title: msg.Title, + itemID: msg.ItemID, + preview: msg.Preview, + rawInput: msg.RawInput, + restore: m.focus, + } +} + +func (m *Model) closeOverlay() { + if m.overlay == nil { + return + } + + m.focus = m.overlay.restore + m.overlay = nil +} + +// activeLayer reports which keymap layer currently has precedence. +func (m *Model) activeLayer() Layer { + if m.overlay != nil { + return LayerOverlay + } + + return LayerRegion +} + +// handleKey routes a keypress through the layer stack: overlay, then the +// focused region, then global. A layer that handles the key stops propagation. +func (m *Model) handleKey(key string) tea.Cmd { + // Any keypress other than the second ctrl+c disarms the quit sequence, so + // an interrupt followed by ordinary typing never quits unexpectedly. + armed := m.interruptArmed + m.interruptArmed = false + + // A notice is transient: it occupies the composer's title, so the next + // keystroke clears it and the title returns to naming the active agent. + // The durable record of anything important is the transcript, not this. + m.notice = nil + + if m.overlay != nil { + return m.overlayKey(key) + } + + if m.regionKey(key) { + return nil + } + + return m.globalKey(key, armed) +} + +func (m *Model) globalKey(key string, armed bool) tea.Cmd { + switch { + case m.keys.Interrupt.Matches(key): + if armed { + m.quitting = true + + return tea.Quit + } + + m.interruptArmed = true + m.send(Interrupt{}) + m.notice = &NoticeMsg{Text: "interrupted — press ctrl+c again to quit", Level: NoticeWarn} + case m.keys.Quit.Matches(key) && m.input.Empty(): + m.quitting = true + + return tea.Quit + case m.keys.NextFocus.Matches(key): + m.moveFocus(false) + case m.keys.PrevFocus.Matches(key): + m.moveFocus(true) + case m.keys.CycleAgent.Matches(key): + m.cycleAgent(false) + case m.keys.ToggleSidebar.Matches(key): + m.sidebarOpen = !m.sidebarOpen + m.relayout() + } + + return nil +} + +// Agent reports the currently selected agent. +func (m *Model) Agent() Agent { return m.agents.Current() } + +// cycleAgent advances the agent selection and announces it. +// +// The selection is local state: no ClientEvent carries an agent profile today. +// AgentSelected is emitted so a bridge can decide what the change means once +// the protocol grows an answer. +func (m *Model) cycleAgent(back bool) { + next := m.agents.cycle(back) + m.send(AgentSelected{Name: next.Name}) + m.notice = &NoticeMsg{Text: next.Name + " — " + next.Description, Level: NoticeInfo} +} + +func (m *Model) moveFocus(back bool) { + ring := focusRing(m.layout, m.hasContent(renderv1.Region_REGION_SIDEBAR)) + m.focus = cycleFocus(m.focus, ring, back) + m.cursor = 0 +} + +// regionKey dispatches to the focused region's bindings, reporting whether the +// key was consumed. +func (m *Model) regionKey(key string) bool { + switch m.focus { + case FocusInput: + return m.inputKey(key) + case FocusMain, FocusSidebar: + return m.contentKey(key) + default: + return false + } +} + +func (m *Model) inputKey(key string) bool { + switch { + case m.keys.Newline.Matches(key): + m.input.Insert("\n") + case m.keys.Submit.Matches(key): + if text, ok := m.input.Submit(); ok { + m.send(SubmitPrompt{Text: text}) + m.pinned = true + } + case m.keys.HistoryPrev.Matches(key) && m.input.OnFirstLine(): + m.input.HistoryPrev() + case m.keys.HistoryNext.Matches(key) && m.input.OnLastLine(): + m.input.HistoryNext() + case key == "backspace": + m.input.Backspace() + case key == "left": + m.input.Left() + case key == "right": + m.input.Right() + case key == "home": + m.input.Home() + case key == "end": + m.input.End() + case key == "space": + // Bubble Tea reports space by name rather than as its literal + // character, so it never reaches the printable check below. + m.input.Insert(" ") + case isPrintable(key): + m.input.Insert(key) + default: + return false + } + + m.relayout() + + return true +} + +func (m *Model) contentKey(key string) bool { + targets := m.targets(m.focusedRegion()) + + switch { + case m.keys.Up.Matches(key): + m.moveCursor(-1, len(targets)) + case m.keys.Down.Matches(key): + m.moveCursor(1, len(targets)) + case m.keys.PageUp.Matches(key): + m.scrollBy(-m.layout.MainInnerHeight()) + case m.keys.PageDown.Matches(key): + m.scrollBy(m.layout.MainInnerHeight()) + case m.keys.Top.Matches(key): + m.scroll, m.pinned = 0, false + case m.keys.Bottom.Matches(key): + m.pinned = true + case m.keys.Activate.Matches(key): + m.activate(targets) + default: + return false + } + + return true +} + +func (m *Model) moveCursor(delta, n int) { + if n == 0 { + m.scrollBy(delta) + + return + } + + m.cursor = clamp(m.cursor+delta, 0, n-1) +} + +func (m *Model) scrollBy(delta int) { + m.scroll = clamp(m.scroll+delta, 0, m.maxScroll) + // Reaching the bottom re-attaches to the live tail, which is what makes + // scrolling down feel like catching up rather than like getting stuck one + // line short of the newest content. + m.pinned = m.scroll >= m.maxScroll +} + +// activate fires the focused target: an action node dispatches unchanged to +// the kernel, a collapsible toggles locally and sends nothing. +func (m *Model) activate(targets []paint.Target) { + if m.cursor < 0 || m.cursor >= len(targets) { + return + } + + t := targets[m.cursor] + switch t.Kind { + case paint.TargetCollapsible: + m.expanded[t.Path] = !m.isExpanded(t.Path) + case paint.TargetAction: + m.send(Trigger{ + NodeID: t.Action.GetId(), + ToolName: t.Action.GetToolName(), + Provider: t.Action.GetProvider(), + Args: t.Action.GetArgs(), + }) + } +} + +func (m *Model) isExpanded(path string) bool { + if v, ok := m.expanded[path]; ok { + return v + } + + return false +} + +func (m *Model) overlayKey(key string) tea.Cmd { + switch { + case m.keys.Allow.Matches(key): + m.resolve(true, ScopeOnce) + case m.keys.Deny.Matches(key): + m.resolve(false, ScopeOnce) + case m.keys.AllowSession.Matches(key): + m.resolve(true, ScopeSession) + case m.keys.Dismiss.Matches(key): + // Dismiss never resolves a pending decision — it would be indefensible + // to turn "go away" into an allow or a deny on the operator's behalf. + m.notice = &NoticeMsg{Text: "decision still pending", Level: NoticeWarn} + } + + return nil +} + +func (m *Model) resolve(allow bool, scope DecisionScope) { + if m.overlay == nil { + return + } + + m.send(Decision{ItemID: m.overlay.itemID, Allow: allow, Scope: scope}) + m.closeOverlay() +} + +func (m *Model) focusedRegion() renderv1.Region { + if m.focus == FocusSidebar { + return renderv1.Region_REGION_SIDEBAR + } + + return renderv1.Region_REGION_MAIN_CHAT +} + +func (m *Model) hasContent(r renderv1.Region) bool { + return len(m.store.Contents(r)) > 0 +} + +// targets enumerates the focusable elements of a region, giving each +// placement a distinct root path so paths stay unique across producers. +func (m *Model) targets(r renderv1.Region) []paint.Target { + var out []paint.Target + + for i, pl := range m.store.Contents(r) { + out = append(out, paint.TargetsAt(pl.Tree, m.expanded, strconv.Itoa(i))...) + } + + return out +} + +func isPrintable(key string) bool { + if len([]rune(key)) != 1 { + return false + } + + r := []rune(key)[0] + + return r >= 0x20 && r != 0x7f +} + +// focusedActionID returns the ActionNode ID under the cursor in a region, or +// empty when the cursor is elsewhere or on a non-action target. +func (m *Model) focusedActionID(r renderv1.Region, focused bool) string { + if !focused { + return "" + } + + targets := m.targets(r) + if m.cursor < 0 || m.cursor >= len(targets) || targets[m.cursor].Kind != paint.TargetAction { + return "" + } + + return targets[m.cursor].Action.GetId() +} + +// focusedPath returns the node path under the cursor in a region. +func (m *Model) focusedPath(r renderv1.Region) string { + targets := m.targets(r) + if m.cursor < 0 || m.cursor >= len(targets) { + return "" + } + + return targets[m.cursor].Path +} + +// paintRegion renders every placement in a region, joined vertically. +func (m *Model) paintRegion(r renderv1.Region, width int, focused bool) string { + placements := m.store.Contents(r) + if len(placements) == 0 { + return "" + } + + items := make([]sidebarItem, 0, len(placements)) + for i, pl := range placements { + items = append(items, sidebarItem{index: i, tree: pl.Tree}) + } + + return m.paintItems(items, width, m.focusedActionID(r, focused)) +} + +// View implements tea.Model. +// +// The frame is composed as a stack of full-width bands — top bar, body, +// composer, status bar — each of which paints every cell it claims. Nothing is +// left uncovered, which together with View's own background and foreground +// colors is what makes the shell read as an application that owns the terminal +// rather than as text printed into someone else's window. +func (m *Model) View() tea.View { + v := tea.NewView(m.frame()) + v.AltScreen = true + v.BackgroundColor = m.th.C.Background + v.ForegroundColor = m.th.C.Text + v.WindowTitle = m.windowTitle() + + // Claim the mouse so the wheel scrolls this transcript rather than the + // terminal's own scrollback behind the alt screen. The tradeoff is that + // drag-selection needs the terminal's override (shift+drag in most). + v.MouseMode = tea.MouseModeCellMotion + + // The real terminal cursor is placed in the composer rather than drawing a + // caret glyph, so it blinks and behaves the way every other terminal + // application's cursor does. It is hidden whenever the composer does not + // own the keyboard. + if m.focus == FocusInput && m.overlay == nil && !m.quitting { + if x, y, ok := m.cursorScreenPos(); ok { + c := tea.NewCursor(x, y) + c.Color = m.th.C.Primary + c.Blink = true + v.Cursor = c + } + } + + return v +} + +func (m *Model) windowTitle() string { + if m.status.Session == "" { + return "pluggableharness" + } + + return "pluggableharness — " + m.status.Session +} + +func (m *Model) frame() string { + if m.quitting { + return "" + } + + l := m.layout + rows := make([]string, 0, l.Height) + + if l.ShowTopBar { + rows = append(rows, m.viewTopBar(l)...) + } + + rows = append(rows, m.viewBody(l)...) + rows = append(rows, m.viewComposer(l)...) + + if l.ShowStatus { + rows = append(rows, m.statusLine(l)) + } + + if l.ShowHints { + rows = append(rows, m.viewHints(l)...) + } + + frame := strings.Join(rows, "\n") + if m.overlay != nil { + frame = m.viewOverlay(l, frame) + } + + return frame +} + +// app is the utility style for cells that belong to the application surface +// rather than to any panel — gutters, gaps, and the space around panels. +func (m *Model) app() ui.Style { + return ui.New().Fg(m.th.C.TextSubtle) +} + +// viewTopBar names what the session is working on. +// +// It is a bordered panel rather than a tinted line. A background fill is the +// obvious way to mark chrome and it does not carry: at these contrast levels a +// tinted row reads as a slightly-off content row, not as a frame. A box reads +// as a box. Its title carries the product name, which is both branding and the +// cheapest possible way to make the border feel deliberate. +// +// The agent is deliberately absent: it belongs beside the composer with the +// other settings that change when it does. What sits here is the answer to +// "where am I" — stable for the whole session, and the first thing an operator +// checks when returning to a window. +func (m *Model) viewTopBar(l Layout) []string { + w := m.workspace + + left := []ui.Segment{ + {Value: ui.New().Fg(m.th.C.Text).Bold().Render(m.workspaceDir(l))}, + {Value: w.Repository, Tone: m.th.C.TextMuted}, + } + + // Session and run state read as one unit, so they share a segment rather + // than being separated by a divider that implies they are different fields. + session := ui.New().Fg(m.th.C.TextSubtle).Render(m.status.Session) + if m.status.Status != "" { + if session != "" { + session += " " + } + + session += ui.Badge(m.th, m.status.Status, m.th.C.Success) + } + + return m.chromePanel(l, "pluggableharness", m.th.C.Primary, ui.StatusLine{ + Segments: left, + Right: []ui.Segment{{Value: session}}, + Width: l.ChromeInnerWidth(), + Flush: true, + }.Render(m.th)) +} + +// chromePanel wraps a single line of chrome in the same bordered box the rest +// of the interface uses, so the header and footer belong to the same visual +// language as the panels between them. +func (m *Model) chromePanel(l Layout, title string, accent color.Color, body string) []string { + panel := ui.Panel{ + Title: title, + Body: body, + Width: l.Width - 2*theme.Gutter, + Height: chromePanelHeight, + Accent: accent, + }.Render(m.th) + + gutter := m.app().Render(strings.Repeat(" ", theme.Gutter)) + + out := make([]string, 0, chromePanelHeight) + for r := range strings.SplitSeq(panel, "\n") { + out = append(out, gutter+r+gutter) + } + + return out +} + +// workspaceDir clips the directory from the left, so a long path keeps the tail +// that identifies it rather than the prefix every path shares. +func (m *Model) workspaceDir(l Layout) string { + return ui.ClipLeft(m.workspace.Directory, max(l.Width/3, 12)) +} + +// viewBody composes the main panel beside the sidebar column, returning one +// string per screen row so the caller can stack bands without measuring. +func (m *Model) viewBody(l Layout) []string { + main := ui.Panel{ + Title: "conversation", + Body: m.mainBody(l), + Width: l.MainWidth, + Height: l.BodyHeight, + Focused: m.focus == FocusMain, + Accent: m.panelAccent(m.focus == FocusMain), + }.Render(m.th) + + mainRows := strings.Split(main, "\n") + gutter := m.app().Render(strings.Repeat(" ", theme.Gutter)) + + if !l.ShowSidebar { + out := make([]string, 0, len(mainRows)) + for _, r := range mainRows { + out = append(out, gutter+r+gutter) + } + + return out + } + + sideRows := m.sidebarColumn(l) + gap := m.app().Render(strings.Repeat(" ", theme.Space1)) + + out := make([]string, 0, l.BodyHeight) + + for i := range l.BodyHeight { + mainRow, sideRow := "", "" + if i < len(mainRows) { + mainRow = mainRows[i] + } + + if i < len(sideRows) { + sideRow = sideRows[i] + } + + out = append(out, gutter+mainRow+gap+ui.Fit(sideRow, l.SidebarWidth)+gutter) + } + + return out +} + +// agentColor is the active agent's tone resolved against the current theme. +func (m *Model) agentColor() color.Color { return m.th.Tone(m.agents.Current().Tone) } + +func (m *Model) panelAccent(focused bool) color.Color { + if focused { + return m.th.C.Primary + } + + return m.th.C.TextSubtle +} + +// mainBody assembles the transcript: placed content, folded sidebar content +// when the terminal is too narrow for a sidebar, and any live streaming text. +func (m *Model) mainBody(l Layout) string { + width := l.MainInnerWidth() + parts := []string{m.paintRegion(renderv1.Region_REGION_MAIN_CHAT, width, m.focus == FocusMain)} + + if l.FoldSidebar() { + parts = append(parts, m.paintRegion(renderv1.Region_REGION_SIDEBAR, width, false)) + } + + for _, s := range m.store.Streams() { + parts = append(parts, m.th.Default.Width(width).Render(s.Text)) + } + + return m.window(strings.Join(nonEmpty(parts), "\n"), l.MainInnerHeight()) +} + +// sidebarColumn renders one panel per contributing producer, stacked. +// +// Giving each producer its own titled panel — rather than concatenating every +// widget into one undifferentiated column — is what makes it obvious which +// plugin contributed what, and it is the visual affordance widget authors +// design against. +func (m *Model) sidebarColumn(l Layout) []string { + const minPanelHeight = 3 + + groups := m.sidebarGroups() + focused := m.focus == FocusSidebar + activeRoot := rootOf(m.focusedPath(renderv1.Region_REGION_SIDEBAR)) + action := m.focusedActionID(renderv1.Region_REGION_SIDEBAR, focused) + + rows := make([]string, 0, l.BodyHeight) + + for _, p := range m.sessionPanels(l) { + if l.BodyHeight-len(rows) < minPanelHeight { + break + } + + p.Width = l.SidebarWidth + p.Height = min(lipgloss.Height(p.Body)+panelChrome, l.BodyHeight-len(rows)) + p.Accent = m.th.C.TextSubtle + + rows = append(rows, strings.Split(p.Render(m.th), "\n")...) + } + + for _, g := range groups { + remaining := l.BodyHeight - len(rows) + if remaining < minPanelHeight { + break + } + + body := m.paintItems(g.items, l.SidebarInnerWidth(), action) + hot := focused && g.holds(activeRoot) + + panel := ui.Panel{ + Title: g.title, + Body: body, + Width: l.SidebarWidth, + Height: min(lipgloss.Height(body)+panelChrome, remaining), + Focused: hot, + Accent: m.panelAccent(hot), + }.Render(m.th) + + rows = append(rows, strings.Split(panel, "\n")...) + } + + // Pad the column so it covers the full body height; an uncovered cell is + // where the terminal's own background shows through. + blank := m.app().Render(strings.Repeat(" ", l.SidebarWidth)) + for len(rows) < l.BodyHeight { + rows = append(rows, blank) + } + + return rows[:l.BodyHeight] +} + +// sidebarItem is one placement plus its index within the region's ordered +// contents. The index is the node path root, so paths stay aligned with what +// targets() enumerates even though panels group placements by producer. +type sidebarItem struct { + index int + tree *renderv1.RenderTree +} + +type sidebarGroup struct { + title string + items []sidebarItem +} + +// holds reports whether this group owns the given path root. +func (g sidebarGroup) holds(root string) bool { + for _, it := range g.items { + if strconv.Itoa(it.index) == root { + return true + } + } + + return false +} + +// sidebarGroups buckets sidebar placements by producer, preserving the store's +// priority ordering and each producer's first appearance within it. +func (m *Model) sidebarGroups() []sidebarGroup { + var groups []sidebarGroup + + at := map[region.Producer]int{} + + for i, pl := range m.store.Contents(renderv1.Region_REGION_SIDEBAR) { + g, ok := at[pl.Producer] + if !ok { + groups = append(groups, sidebarGroup{title: pl.Producer.Name}) + g = len(groups) - 1 + at[pl.Producer] = g + } + + groups[g].items = append(groups[g].items, sidebarItem{index: i, tree: pl.Tree}) + } + + return groups +} + +// paintItems renders a group's placements, each rooted at its own path. +func (m *Model) paintItems(items []sidebarItem, width int, focusedAction string) string { + parts := make([]string, 0, len(items)) + + for _, it := range items { + parts = append(parts, m.painter.TreeAt(it.tree, paint.Opts{ + Width: width, + FocusedAction: focusedAction, + Expanded: m.expanded, + }, strconv.Itoa(it.index))) + } + + return strings.Join(parts, "\n") +} + +// rootOf returns the leading path segment, which identifies the placement a +// target belongs to. +func rootOf(path string) string { + if i := strings.IndexByte(path, '.'); i >= 0 { + return path[:i] + } + + return path +} + +// window clips content to a visible height, pinning to the live tail unless the +// operator has scrolled away from it. +// +// Content shorter than the viewport is pushed to the *bottom* rather than left +// at the top. A transcript grows upward from the composer the way every chat +// interface does: the newest message belongs next to where the operator is +// typing, and the empty space belongs above it, out of the way. Top-anchoring +// instead strands the last message a screen away from the input. +func (m *Model) window(content string, height int) string { + lines := strings.Split(content, "\n") + + if pad := height - len(lines); pad > 0 { + lines = append(make([]string, pad), lines...) + } + + m.maxScroll = max(len(lines)-height, 0) + + // While pinned, the offset tracks the tail rather than sitting at zero, so + // the first scroll away from the live edge starts from where the operator + // is actually looking instead of jumping to the top of the transcript. + if m.pinned { + m.scroll = m.maxScroll + } + + top := clamp(m.scroll, 0, m.maxScroll) + end := min(top+height, len(lines)) + + return strings.Join(lines[top:end], "\n") +} + +func (m *Model) viewComposer(l Layout) []string { + prompt := ui.New().Fg(m.agentColor()).Bold().Render("› ") + + placeholder := "" + if m.focus == FocusInput { + placeholder = ui.New().Fg(m.th.C.TextSubtle). + Render("ask anything, or / for commands") + } + + body := m.input.render(placeholder) + lines := strings.Split(body, "\n") + + for i := range lines { + if i == 0 { + lines[i] = prompt + lines[i] + + continue + } + + lines[i] = " " + lines[i] + } + + // The agent takes the near corner and the model takes the far one. + // + // They answer different questions — the agent is *who you are talking to* + // and changes on a keystroke, the model is *what is behind it* and changes + // rarely — but run together in one title they became a four-part string in + // which neither was findable, and the agent's color bled onto settings it + // does not own. Split across the diagonal, the identity sits where the eye + // already goes for a panel's name and the configuration sits out of the way + // until looked for. + title := m.agents.Current().Name + if m.notice != nil { + title = m.notice.Text + } + + panel := ui.Panel{ + Title: title, + Caption: m.modelCaption(), + Body: strings.Join(lines, "\n"), + Width: l.Width - 2*theme.Gutter, + Height: l.ComposerHeight, + Focused: m.focus == FocusInput, + Accent: m.noticeAccent(), + }.Render(m.th) + + gutter := m.app().Render(strings.Repeat(" ", theme.Gutter)) + out := make([]string, 0, l.ComposerHeight) + + for _, r := range strings.Split(panel, "\n") { + out = append(out, gutter+r+gutter) + } + + return out +} + +// modelCaption is the model configuration that rides in the composer's +// bottom-right corner: which model, and how it is set to reason. +// +// Thinking and effort are joined with a slash rather than given separators of +// their own. They are one setting read two ways — "extended thinking at high +// effort" — and promoting each to a peer of the model name made three equal +// fields out of a name and its two modifiers. +// +// Absent a model there is no caption at all: a lone "extended/high" names a +// setting without saying what it applies to. +func (m *Model) modelCaption() string { + if m.status.Model == "" { + return "" + } + + reasoning := strings.Join(nonEmpty([]string{m.status.Thinking, m.status.Effort}), "/") + + return strings.Join(nonEmpty([]string{m.status.Model, reasoning}), " · ") +} + +// noticeAccent colors the composer's title by the severity of the most recent +// notice, which is how errors and rejected client events stay visible without +// stealing a row from the transcript. +func (m *Model) noticeAccent() color.Color { + if m.notice == nil { + // With nothing to report, the composer wears the active agent's color, + // which is what keeps the current mode visible without spending a row + // on saying so. + return m.agentColor() + } + + switch m.notice.Level { + case NoticeError: + return m.th.C.Danger + case NoticeWarn: + return m.th.C.Warning + case NoticeInfo: + return m.th.C.Info + default: + return m.th.C.TextMuted + } +} + +func (m *Model) viewHints(l Layout) []string { + inner := l.ChromeInnerWidth() + + // The right-hand label names whatever currently owns the keyboard, which is + // the overlay while one is up rather than the region underneath it. + owner := m.focus.String() + if m.overlay != nil { + owner = "permission" + } + + // Context pressure outranks the focus label. Which region holds the + // keyboard is recoverable by looking at the borders; running out of context + // is not visible anywhere else on this line. + tone := m.th.C.Primary + if warning := m.contextWarning(); warning != "" { + owner, tone = warning, m.th.C.Danger + } + + // The hints are long enough to crowd out the right-hand label, and a line + // sheds its right group first — so without bounding them the warning would + // be the thing that disappears. Keys are a reminder and always recoverable; + // running out of context is news. + room := inner - lipgloss.Width(owner) - lipgloss.Width(ui.SegmentSeparator) + + hints := m.keys.Hints(m.activeLayer(), m.focus, l.SidebarAvailable, max(room, 0)) + if contributed := m.paintRegion(renderv1.Region_REGION_HOTKEY_HINTS, room, false); contributed != "" { + hints = ui.Clip(contributed, max(room, 0)) + } + + return m.chromePanel(l, "keys", m.th.C.TextSubtle, ui.StatusLine{ + Segments: []ui.Segment{{Value: hints, Tone: m.th.C.TextSubtle}}, + Right: []ui.Segment{{Value: owner, Tone: tone}}, + Width: inner, + Flush: true, + }.Render(m.th)) +} + +// cursorScreenPos maps the composer's buffer cursor onto absolute screen +// coordinates. +func (m *Model) cursorScreenPos() (x, y int, ok bool) { + l := m.layout + + line, col := m.input.CursorPos() + if line >= l.InputHeight { + return 0, 0, false + } + + // Gutter, panel border, panel padding, then the two-cell prompt on line 0. + x = theme.Gutter + 1 + panelPadding + 2 + col + y = l.ComposerTop() + 1 + line + + if x >= l.Width || y >= l.Height { + return 0, 0, false + } + + return x, y, true +} + +// viewOverlay composites the modal pane on top of the composed frame. +// +// The frame underneath is preserved rather than blanked: an operator deciding +// whether to allow a tool call needs to see the transcript that led to it, and +// a full-screen takeover would hide exactly the context the decision depends +// on. The pane is drawn on the element surface with an active border, which is +// what makes it read as elevated above the panels behind it. +func (m *Model) viewOverlay(l Layout, frame string) string { + // The preferred width is two thirds of the screen, but the terminal always + // wins: on a very small terminal the desired minimum exceeds the available + // width, and a pane wider than the screen wraps and corrupts every row + // beneath it. + width := clamp(l.Width*2/3, 24, max(l.Width-2*theme.Space2, 1)) + inner := max(width-panelChrome-2*panelPadding, 1) + + parts := []string{ui.New().Fg(m.th.C.Text).Bold().Render(m.overlay.title)} + + switch { + case m.overlay.preview != nil: + parts = append(parts, "", m.painter.Tree(m.overlay.preview, paint.Opts{Width: inner})) + case m.overlay.rawInput != "": + // The plan/apply gate requires falling back to the raw input when a + // provider supplied no preview, rather than showing nothing. + parts = append(parts, "", m.th.Code.Width(inner).Render(m.overlay.rawInput)) + } + + parts = append(parts, "", + ui.New().Fg(m.th.C.TextSubtle). + Render(m.keys.Hints(LayerOverlay, m.focus, false, inner))) + + body := strings.Join(parts, "\n") + height := min(lipgloss.Height(body)+panelChrome, max(l.Height-2, 3)) + + pane := ui.Panel{ + Title: "permission", + Body: body, + Width: width, + Height: height, + Focused: true, + Accent: m.th.C.Warning, + }.Render(m.th) + + x := max((l.Width-width)/2, 0) + y := max((l.Height-height)/2, 0) + + return ui.Overlay(frame, pane, x, y) +} + +func nonEmpty(in []string) []string { + out := make([]string, 0, len(in)) + + for _, s := range in { + if s != "" { + out = append(out, s) + } + } + + return out +} + +// contextDangerAt is the fraction of the effective ceiling past which the +// status line says so in words. +// The hue itself is continuous across the ramp; this threshold governs only +// the text warning, which needs a discrete moment to appear at. +const contextDangerAt = 0.85 + +// contextFill reports context pressure as a fraction, and whether it is known. +func (m *Model) contextFill() (float64, bool) { + // A non-positive ceiling means the kernel has not resolved a budget for + // this session yet; dividing by it would invent a number. + if m.usage == nil || m.usage.EffectiveCeiling <= 0 { + return 0, false + } + + return float64(m.usage.UsedTokens) / float64(m.usage.EffectiveCeiling), true +} + +// contextTone resolves the pressure hue from the theme's gauge ramp, which runs +// green through amber to red by default and is configurable as a list of tone +// names rather than colors. +func (m *Model) contextTone(fill float64) color.Color { + return m.th.GaugeRamp.At(m.th, fill) +} + +// contextWarning returns the status-line warning for high context pressure, or +// empty when there is nothing to say. +// +// It deliberately names no command. Compaction in this system is automatic — a +// context provider declaring compactor: true receives the conversation history +// and returns a rewritten one on its own initiative — so there is no operator +// action like "/compact" to point at, and inventing one would be worse than +// saying nothing. The honest message is that room is running out. +func (m *Model) contextWarning() string { + fill, ok := m.contextFill() + if !ok || fill < contextDangerAt { + return "" + } + + return "context nearly full" +} + +// statusLine is the single row beneath the composer. +// +// It carries only what changes during a turn — context, cache, cost, elapsed. +// Everything static about a session (model, directory, repository) lives in the +// sidebar instead, because a field that never changes does not earn a place +// beside the input box. Volatile data goes where the operator is already +// looking; reference data goes to the periphery. +func (m *Model) statusLine(l Layout) string { + // Context is the only left-hand segment, and everything else is pinned + // right. That is not cosmetic: a line drops left segments right-to-left, so + // anything sitting beside context would survive at its expense — and + // context is the field worth keeping longest. Alone on the left, it can + // never be dropped, and it still reads as the growing middle because the + // right cluster is what it grows against. + var left []ui.Segment + if seg, ok := m.contextSegment(); ok { + left = append(left, seg) + } + + right := []ui.Segment{} + if rate, ok := m.usage.cacheRate(); ok { + right = append(right, ui.Segment{Label: "cache", Value: formatPercent(rate), Tone: m.th.C.Info}) + } + + if m.usage != nil { + right = append(right, ui.Segment{ + Label: "cost", + Value: formatUSD(m.usage.CumulativeCostUSD), + Tone: m.th.C.Warning, + }) + } + + right = append(right, ui.Segment{ + Label: "elapsed", + Value: formatDuration(m.status.Elapsed), + Tone: m.th.C.TextMuted, + }) + + inset := m.app().Render(strings.Repeat(" ", StatusInset)) + + return inset + ui.StatusLine{ + Segments: left, + Right: right, + Width: l.StatusWidth(), + }.Render(m.th) + inset +} + +// contextSegment is the growing middle of the status line: the gradient meter, +// the absolute figures, and the percentage. +// +// It sits between the fixed groups so it absorbs every spare cell — the meter +// is the one thing on the line that benefits from more room. The absolute +// figures ride immediately after the bar rather than at the far edge, which is +// what keeps a long bar from ending in a number marooned across the screen. +func (m *Model) contextSegment() (ui.Segment, bool) { + fill, ok := m.contextFill() + if !ok { + return ui.Segment{}, false + } + + tone := m.contextTone(fill) + figures := formatTokens(m.usage.UsedTokens) + " / " + formatTokens(m.usage.EffectiveCeiling) + + return ui.Segment{ + MinWidth: minContextSegment, + Fill: func(width int) string { + label := ui.New().Fg(m.th.C.TextSubtle).Render("context ") + tail := ui.New().Fg(m.th.C.TextMuted).Render(" "+figures+" ") + + ui.New().Fg(tone).Bold().Render(formatPercent(fill)) + + bar := width - lipgloss.Width(label) - lipgloss.Width(tail) + if bar < minMeterBar { + return label + ui.New().Fg(tone).Render(figures+" "+formatPercent(fill)) + } + + return label + ui.GradientMeter(m.th, m.th.GaugeRamp, bar, fill) + tail + }, + }, true +} + +// sessionPanels are the shell's own sidebar panels: the session reference data +// that used to crowd the composer. +// +// They are built here rather than contributed by a plugin because the shell +// already has the data — but they render exactly like a widget's panel, which +// is deliberate. A widget author looking at the sidebar should see one visual +// language, not shell chrome sitting apart from plugin content. +func (m *Model) sessionPanels(l Layout) []ui.Panel { + inner := l.SidebarInnerWidth() + + usage := ui.Fields(m.th, []ui.Field{ + {Label: "out", Value: m.usage.tokens(func(u *UsageMsg) int64 { return u.OutputTokens })}, + {Label: "in", Value: m.usage.tokens(func(u *UsageMsg) int64 { return u.InputTokens + u.CacheReadTokens })}, + {Label: "cached", Value: m.usage.tokens(func(u *UsageMsg) int64 { return u.CacheWriteTokens })}, + {Label: "lines", Value: m.edits.summary()}, + }, inner) + + panels := make([]ui.Panel, 0, 1) + for _, p := range []ui.Panel{ + {Title: "usage", Body: usage}, + } { + if p.Body != "" { + panels = append(panels, p) + } + } + + return panels +} diff --git a/internal/tui/shell/model_test.go b/internal/tui/shell/model_test.go new file mode 100644 index 0000000..e1295c0 --- /dev/null +++ b/internal/tui/shell/model_test.go @@ -0,0 +1,1449 @@ +package shell + +import ( + "context" + "fmt" + "image/color" + "regexp" + "strings" + "testing" + "time" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + + "github.com/pluggableharness/agent/internal/tui/region" + "github.com/pluggableharness/agent/pkg/render" + renderv1 "github.com/pluggableharness/agent/pkg/render/proto/v1" +) + +var ansiPattern = regexp.MustCompile(`\x1b\[[0-9;]*[a-zA-Z]`) + +func plain(s string) string { return ansiPattern.ReplaceAllString(s, "") } + +// recorder captures the actions a model emits so tests can assert on what the +// shell would have sent to the kernel. +type recorder struct{ actions []Action } + +func (r *recorder) emit(a Action) { r.actions = append(r.actions, a) } + +func (r *recorder) last(t *testing.T) Action { + t.Helper() + + if len(r.actions) == 0 { + t.Fatal("no action was emitted") + } + + return r.actions[len(r.actions)-1] +} + +// newTestModel returns a model sized to a wide terminal with an emitter wired. +func newTestModel(t *testing.T) (*Model, *recorder) { + t.Helper() + + rec := &recorder{} + m := New(WithEmitter(rec.emit)) + m.Update(tea.WindowSizeMsg{Width: 120, Height: 40}) + + return m, rec +} + +func press(t *testing.T, m *Model, keys ...string) { + t.Helper() + + for _, k := range keys { + m.Update(key(k)) + } +} + +// key builds a keypress whose String() matches what the shell binds against. +// Printable single characters carry Text; everything else is a named key. +func key(s string) tea.KeyPressMsg { + if len([]rune(s)) == 1 && s != " " { + r := []rune(s)[0] + + return tea.KeyPressMsg{Code: r, Text: s} + } + + switch s { + case "enter": + return tea.KeyPressMsg{Code: tea.KeyEnter} + case "alt+enter": + return tea.KeyPressMsg{Code: tea.KeyEnter, Mod: tea.ModAlt} + case "shift+enter": + return tea.KeyPressMsg{Code: tea.KeyEnter, Mod: tea.ModShift} + case "ctrl+j": + return tea.KeyPressMsg{Code: 'j', Mod: tea.ModCtrl} + case "tab": + return tea.KeyPressMsg{Code: tea.KeyTab} + case "shift+tab": + return tea.KeyPressMsg{Code: tea.KeyTab, Mod: tea.ModShift} + case "backspace": + return tea.KeyPressMsg{Code: tea.KeyBackspace} + case "up": + return tea.KeyPressMsg{Code: tea.KeyUp} + case "down": + return tea.KeyPressMsg{Code: tea.KeyDown} + case "esc": + return tea.KeyPressMsg{Code: tea.KeyEscape} + case "ctrl+c": + return tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl} + case "ctrl+d": + return tea.KeyPressMsg{Code: 'd', Mod: tea.ModCtrl} + case "ctrl+b": + return tea.KeyPressMsg{Code: 'b', Mod: tea.ModCtrl} + default: + return tea.KeyPressMsg{Code: tea.KeyEnter} + } +} + +func placeMsg(r renderv1.Region, text string, seq uint64) PlaceMsg { + return PlaceMsg{ + Producer: region.Producer{Category: "tool", Name: "fs"}, + Sequence: seq, + Content: &renderv1.PlacedContent{ + Region: r, + Content: render.Tree(render.Text(text)), + }, + } +} + +// The key helper must produce the strings the keymap actually binds, otherwise +// every keyboard test below would be vacuous. +func TestKeyHelperMatchesBindings(t *testing.T) { + t.Parallel() + + tests := map[string]string{ + "ctrl+c": "ctrl+c", "ctrl+d": "ctrl+d", "ctrl+b": "ctrl+b", + "tab": "tab", "shift+tab": "shift+tab", "enter": "enter", + "alt+enter": "alt+enter", "up": "up", "down": "down", + "esc": "esc", "backspace": "backspace", "y": "y", "a": "a", + "shift+enter": "shift+enter", "ctrl+j": "ctrl+j", + } + + for in, want := range tests { + if got := key(in).String(); got != want { + t.Errorf("key(%q).String() = %q, want %q", in, got, want) + } + } +} + +func TestTypingAndSubmitting(t *testing.T) { + t.Parallel() + + m, rec := newTestModel(t) + press(t, m, "h", "i") + + if got := m.input.Value(); got != "hi" { + t.Fatalf("input = %q, want %q", got, "hi") + } + + press(t, m, "enter") + + got, ok := rec.last(t).(SubmitPrompt) + if !ok { + t.Fatalf("emitted %T, want SubmitPrompt", rec.last(t)) + } + + if got.Text != "hi" { + t.Fatalf("SubmitPrompt.Text = %q, want %q", got.Text, "hi") + } +} + +func TestAltEnterInsertsNewlineInsteadOfSubmitting(t *testing.T) { + t.Parallel() + + m, rec := newTestModel(t) + press(t, m, "a", "alt+enter", "b") + + if got := m.input.Value(); got != "a\nb" { + t.Fatalf("input = %q, want %q", got, "a\nb") + } + + if len(rec.actions) != 0 { + t.Fatalf("alt+enter submitted: %+v", rec.actions) + } + + // The composer growing must be reflected in the solved layout. + if m.Layout().InputHeight != 2 { + t.Fatalf("InputHeight = %d, want 2", m.Layout().InputHeight) + } +} + +func TestFocusCyclesAndSkipsEmptySidebar(t *testing.T) { + t.Parallel() + + m, _ := newTestModel(t) + + if m.Focus() != FocusInput { + t.Fatalf("startup focus = %v, want input", m.Focus()) + } + + press(t, m, "tab") + + if m.Focus() != FocusMain { + t.Fatalf("after tab = %v, want main", m.Focus()) + } + + // The sidebar is on screen but empty, so it is not in the ring. + press(t, m, "tab") + + if m.Focus() != FocusInput { + t.Fatalf("after second tab = %v, want input (empty sidebar skipped)", m.Focus()) + } + + // Give the sidebar content and it joins the ring. + m.Update(placeMsg(renderv1.Region_REGION_SIDEBAR, "git", 1)) + press(t, m, "tab", "tab") + + if m.Focus() != FocusSidebar { + t.Fatalf("with sidebar content, focus = %v, want sidebar", m.Focus()) + } +} + +func TestStreamingDeltasAccumulateAndSettle(t *testing.T) { + t.Parallel() + + m, _ := newTestModel(t) + + m.Update(DeltaMsg{TargetID: "t1", Text: "hello "}) + m.Update(DeltaMsg{TargetID: "t1", Text: "world"}) + + streams := m.Store().Streams() + if len(streams) != 1 || streams[0].Text != "hello world" { + t.Fatalf("deltas did not accumulate: %+v", streams) + } + + if got := plain(m.View().Content); !strings.Contains(got, "hello world") { + t.Fatalf("streamed text not painted: %q", got) + } + + m.Update(SettledMsg{TargetID: "t1"}) + + if len(m.Store().Streams()) != 0 { + t.Fatal("SettledMsg left the live buffer in place; content would appear twice") + } +} + +// A finished render replaces the live buffer rather than appearing beside it. +func TestPlaceClearsLiveStreams(t *testing.T) { + t.Parallel() + + m, _ := newTestModel(t) + m.Update(DeltaMsg{TargetID: "t1", Text: "partial"}) + m.Update(placeMsg(renderv1.Region_REGION_MAIN_CHAT, "final", 1)) + + if len(m.Store().Streams()) != 0 { + t.Fatal("a settled render left the streaming buffer live") + } + + got := plain(m.View().Content) + if !strings.Contains(got, "final") { + t.Fatalf("final content missing: %q", got) + } + + if strings.Contains(got, "partial") { + t.Fatalf("streamed text painted twice: %q", got) + } +} + +func TestOverlayIsModalAndRestoresFocus(t *testing.T) { + t.Parallel() + + m, _ := newTestModel(t) + press(t, m, "tab") // focus main + + m.Update(PermissionMsg{ItemID: "item_1", Title: "Allow write_file?"}) + + if m.activeLayer() != LayerOverlay { + t.Fatal("overlay did not take the keymap layer") + } + + // While modal, region bindings must not reach the shell. + press(t, m, "tab") + + if m.Focus() != FocusMain { + t.Fatalf("tab changed focus while modal: %v", m.Focus()) + } + + if got := plain(m.View().Content); !strings.Contains(got, "Allow write_file?") { + t.Fatalf("overlay not painted: %q", got) + } + + press(t, m, "y") + + if m.activeLayer() != LayerRegion { + t.Fatal("overlay stayed up after a decision") + } + + if m.Focus() != FocusMain { + t.Fatalf("focus not restored after overlay: %v", m.Focus()) + } +} + +func TestPlanDecisionScopes(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + key string + wantAllow bool + wantScope DecisionScope + }{ + {"allow once", "y", true, ScopeOnce}, + {"deny once", "n", false, ScopeOnce}, + {"allow for session", "a", true, ScopeSession}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + m, rec := newTestModel(t) + m.Update(PermissionMsg{ItemID: "item_1", Title: "?"}) + press(t, m, tc.key) + + got, ok := rec.last(t).(Decision) + if !ok { + t.Fatalf("emitted %T, want Decision", rec.last(t)) + } + + if got.ItemID != "item_1" || got.Allow != tc.wantAllow || got.Scope != tc.wantScope { + t.Fatalf("Decision = %+v, want allow=%v scope=%v", got, tc.wantAllow, tc.wantScope) + } + }) + } +} + +// Dismissing must never resolve a pending decision on the operator's behalf. +func TestEscapeDoesNotResolveAPendingDecision(t *testing.T) { + t.Parallel() + + m, rec := newTestModel(t) + m.Update(PermissionMsg{ItemID: "item_1", Title: "?"}) + press(t, m, "esc") + + if len(rec.actions) != 0 { + t.Fatalf("esc emitted %+v, want nothing", rec.actions) + } + + if m.activeLayer() != LayerOverlay { + t.Fatal("esc dismissed a pending decision overlay") + } +} + +// An operator deciding whether to allow a tool call needs to see the +// transcript that led to it, so the overlay composites over the frame rather +// than blanking it. +func TestOverlayPreservesTheFrameBeneathIt(t *testing.T) { + t.Parallel() + + m, _ := newTestModel(t) + m.Update(placeMsg(renderv1.Region_REGION_MAIN_CHAT, "earlier transcript line", 1)) + m.Update(placeMsg(renderv1.Region_REGION_SIDEBAR, "sidebar widget", 2)) + m.Update(StatusMsg{Session: "session-01", Model: "claude-opus-5"}) + m.Update(PermissionMsg{ItemID: "i", Title: "Allow?"}) + + got := plain(m.View().Content) + + for _, want := range []string{"Allow?", "earlier transcript line", "sidebar widget", "session-01"} { + if !strings.Contains(got, want) { + t.Errorf("overlay frame missing %q:\n%s", want, got) + } + } +} + +// The sidebar column must actually paint its content in the wide layout, not +// merely reserve space for it. +func TestSidebarContentPaintsInWideLayout(t *testing.T) { + t.Parallel() + + m, _ := newTestModel(t) + m.Update(placeMsg(renderv1.Region_REGION_SIDEBAR, "branch: main", 1)) + + if !m.Layout().ShowSidebar { + t.Fatalf("sidebar not shown at width 120: %+v", m.Layout()) + } + + if got := plain(m.View().Content); !strings.Contains(got, "branch: main") { + t.Fatalf("sidebar content missing from the frame:\n%s", got) + } +} + +// When a provider supplied no preview, the raw input is shown rather than an +// empty prompt. +func TestOverlayFallsBackToRawInput(t *testing.T) { + t.Parallel() + + m, _ := newTestModel(t) + m.Update(PermissionMsg{ItemID: "i", Title: "Allow?", RawInput: `{"path":"/etc/hosts"}`}) + + if got := plain(m.View().Content); !strings.Contains(got, "/etc/hosts") { + t.Fatalf("raw input fallback not painted: %q", got) + } +} + +func TestActionTriggerDispatchesUnchanged(t *testing.T) { + t.Parallel() + + m, rec := newTestModel(t) + m.Update(PlaceMsg{ + Producer: region.Producer{Category: "widget", Name: "w"}, + Sequence: 1, + Content: &renderv1.PlacedContent{ + Region: renderv1.Region_REGION_MAIN_CHAT, + Content: render.Tree(render.Action("act_1", "Compact", "compact_context", nil, "builtin")), + }, + }) + + press(t, m, "tab") // focus main + press(t, m, "enter") + + got, ok := rec.last(t).(Trigger) + if !ok { + t.Fatalf("emitted %T, want Trigger", rec.last(t)) + } + + if got.NodeID != "act_1" || got.ToolName != "compact_context" || got.Provider != "builtin" { + t.Fatalf("Trigger = %+v; tool name, provider and node id must pass through unchanged", got) + } +} + +func TestActivatingACollapsibleTogglesLocallyAndSendsNothing(t *testing.T) { + t.Parallel() + + m, rec := newTestModel(t) + m.Update(PlaceMsg{ + Producer: region.Producer{Category: "tool", Name: "fs"}, + Sequence: 1, + Content: &renderv1.PlacedContent{ + Region: renderv1.Region_REGION_MAIN_CHAT, + Content: render.Tree(render.CollapsedByDefault("summary", render.Text("hidden body"))), + }, + }) + + press(t, m, "tab") + + if got := plain(m.View().Content); strings.Contains(got, "hidden body") { + t.Fatalf("collapsed content was visible: %q", got) + } + + press(t, m, "enter") + + if len(rec.actions) != 0 { + t.Fatalf("toggling a collapsible sent %+v to the kernel", rec.actions) + } + + if got := plain(m.View().Content); !strings.Contains(got, "hidden body") { + t.Fatalf("collapsible did not expand: %q", got) + } +} + +func TestInterruptThenQuit(t *testing.T) { + t.Parallel() + + m, rec := newTestModel(t) + press(t, m, "ctrl+c") + + if _, ok := rec.last(t).(Interrupt); !ok { + t.Fatalf("first ctrl+c emitted %T, want Interrupt", rec.last(t)) + } + + if m.quitting { + t.Fatal("first ctrl+c quit immediately") + } + + _, cmd := m.Update(key("ctrl+c")) + + if !m.quitting || cmd == nil { + t.Fatal("second ctrl+c did not quit") + } +} + +// An interrupt followed by ordinary typing must not quit on the next ctrl+c. +func TestTypingDisarmsTheQuitSequence(t *testing.T) { + t.Parallel() + + m, _ := newTestModel(t) + press(t, m, "ctrl+c", "x", "ctrl+c") + + if m.quitting { + t.Fatal("quit sequence survived an intervening keystroke") + } +} + +func TestCtrlDQuitsOnlyOnAnEmptyComposer(t *testing.T) { + t.Parallel() + + m, _ := newTestModel(t) + press(t, m, "x", "ctrl+d") + + if m.quitting { + t.Fatal("ctrl+d quit with text in the composer") + } + + press(t, m, "backspace", "ctrl+d") + + if !m.quitting { + t.Fatal("ctrl+d did not quit on an empty composer") + } +} + +func TestSidebarToggle(t *testing.T) { + t.Parallel() + + m, _ := newTestModel(t) + m.Update(tea.WindowSizeMsg{Width: 80, Height: 40}) // between floor and breakpoint + + if m.Layout().ShowSidebar { + t.Fatal("sidebar shown by default on a medium terminal") + } + + press(t, m, "ctrl+b") + + if !m.Layout().ShowSidebar { + t.Fatal("ctrl+b did not open the sidebar") + } + + press(t, m, "ctrl+b") + + if m.Layout().ShowSidebar { + t.Fatal("ctrl+b did not close the sidebar") + } +} + +// Below the floor width the sidebar cannot exist as a pane, so its content +// folds into main_chat rather than being dropped. +func TestNarrowTerminalFoldsSidebarContentIntoMainChat(t *testing.T) { + t.Parallel() + + m, _ := newTestModel(t) + m.Update(placeMsg(renderv1.Region_REGION_SIDEBAR, "git status", 1)) + m.Update(tea.WindowSizeMsg{Width: 50, Height: 24}) + + if !m.Layout().FoldSidebar() { + t.Fatal("expected the layout to fold the sidebar at width 50") + } + + if got := plain(m.View().Content); !strings.Contains(got, "git status") { + t.Fatalf("folded sidebar content was dropped: %q", got) + } +} + +func TestNoticesAreSurfaced(t *testing.T) { + t.Parallel() + + m, _ := newTestModel(t) + m.Update(NoticeMsg{Text: "already decided elsewhere", Level: NoticeError}) + + if got := plain(m.View().Content); !strings.Contains(got, "already decided elsewhere") { + t.Fatalf("notice not painted: %q", got) + } +} + +// A late decision the kernel rejected must clear the overlay and say why, +// rather than leaving the UI looking hung. +func TestDismissOverlayMsgClearsAndExplains(t *testing.T) { + t.Parallel() + + m, _ := newTestModel(t) + m.Update(PermissionMsg{ItemID: "i", Title: "?"}) + m.Update(DismissOverlayMsg{Reason: "already decided elsewhere"}) + + if m.activeLayer() != LayerRegion { + t.Fatal("overlay survived a kernel-side dismissal") + } + + if got := plain(m.View().Content); !strings.Contains(got, "already decided elsewhere") { + t.Fatalf("dismissal reason not surfaced: %q", got) + } +} + +func TestStatusMsgPaintsInTopBar(t *testing.T) { + t.Parallel() + + m, _ := newTestModel(t) + m.Update(StatusMsg{Session: "session-01", Model: "claude-opus-5", Status: "ready"}) + + header := headerRows(t, m) + + // Session and run state are the header's right-hand group; a line with an + // empty left group still renders them. + for _, want := range []string{"session-01", "ready"} { + if !strings.Contains(header, want) { + t.Errorf("header missing %q:\n%s", want, header) + } + } +} + +func TestViewIsAltScreenAndSurvivesQuitting(t *testing.T) { + t.Parallel() + + m, _ := newTestModel(t) + + if !m.View().AltScreen { + t.Error("view did not declare AltScreen; the shell is a full-screen takeover") + } + + m.quitting = true + + if v := m.View(); v.Content != "" || !v.AltScreen { + t.Errorf("quitting view = %+v, want empty content with AltScreen set", v) + } +} + +func TestModelWithoutEmitterDoesNotPanic(t *testing.T) { + t.Parallel() + + m := New() + m.Update(tea.WindowSizeMsg{Width: 100, Height: 30}) + press(t, m, "h", "enter", "ctrl+c") + + // Reaching here without a panic is the assertion: a rendering-only model + // has nowhere to send, and that must be a supported configuration. + if m.input.Value() != "" { + t.Fatalf("submit did not clear the composer: %q", m.input.Value()) + } +} + +func TestInitReturnsNoCommand(t *testing.T) { + t.Parallel() + + if cmd := New().Init(); cmd != nil { + t.Fatal("Init returned a command; the event source runs outside the program") + } +} + +func TestDemoSourceEmitsItsScriptAndStopsOnCancel(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithCancel(context.Background()) + + var got []tea.Msg + + done := make(chan error, 1) + + go func() { + done <- DemoSource{}.Run(ctx, func(m tea.Msg) { got = append(got, m) }) + }() + + // The source blocks on ctx after emitting its script; canceling is the + // documented way it ends, and it must report that as success rather than + // as an error. + cancel() + + if err := <-done; err != nil { + t.Fatalf("Run returned %v, want nil on cancellation", err) + } + + if len(demoScript()) == 0 { + t.Fatal("demo script is empty; the skeleton would show nothing") + } +} + +// The demo fixture must exercise every region the shell lays out, since that +// is the whole reason it exists ahead of the kernel bridge. +func TestDemoScriptCoversTheMainRegions(t *testing.T) { + t.Parallel() + + seen := map[renderv1.Region]bool{} + + for _, msg := range demoScript() { + if p, ok := msg.(PlaceMsg); ok { + seen[p.Content.GetRegion()] = true + } + } + + for _, want := range []renderv1.Region{ + renderv1.Region_REGION_MAIN_CHAT, + renderv1.Region_REGION_SIDEBAR, + } { + if !seen[want] { + t.Errorf("demo script never places content in %v", want) + } + } +} + +// Every frame must be exactly Height rows of exactly Width cells. +// +// This is the invariant that makes the shell a full-screen application rather +// than text printed into someone else's window: an uncovered cell shows the +// terminal's own background, and a row wider than the terminal wraps and +// shifts everything below it. It is also the cheapest way to catch a layout +// arithmetic slip, which is otherwise only visible by eye. +func TestFrameCoversTheTerminalExactly(t *testing.T) { + t.Parallel() + + sizes := [][2]int{ + {120, 30}, {100, 24}, {80, 24}, {72, 20}, + {64, 18}, {52, 16}, {40, 12}, {30, 9}, {20, 6}, + } + + for _, size := range sizes { + w, h := size[0], size[1] + + t.Run(fmt.Sprintf("%dx%d", w, h), func(t *testing.T) { + t.Parallel() + + for _, withOverlay := range []bool{false, true} { + m := New() + m.Update(tea.WindowSizeMsg{Width: w, Height: h}) + + for _, msg := range demoScript() { + m.Update(msg) + } + + if !withOverlay { + m.Update(key("y")) // resolve the demo's permission prompt + } + + rows := strings.Split(m.View().Content, "\n") + if len(rows) != h { + t.Fatalf("overlay=%v: got %d rows, want %d", withOverlay, len(rows), h) + } + + for i, r := range rows { + if got := lipgloss.Width(r); got != w { + t.Errorf("overlay=%v row %d: width %d, want %d: %q", + withOverlay, i, got, w, plain(r)) + } + } + } + }) + } +} + +// Content carrying tabs must not overflow its pane. A tab measures as zero +// cells but a terminal advances to the next tab stop when drawing one, so an +// unexpanded tab silently pushes a row past the terminal width. +func TestTabbedContentDoesNotOverflow(t *testing.T) { + t.Parallel() + + m := New() + m.Update(tea.WindowSizeMsg{Width: 72, Height: 20}) + m.Update(PlaceMsg{ + Producer: region.Producer{Category: "tool", Name: "fs"}, + Sequence: 1, + Content: &renderv1.PlacedContent{ + Region: renderv1.Region_REGION_MAIN_CHAT, + Content: render.Tree(render.Code("go", "func main() {\n\tif x {\n\t\treturn\n\t}\n}")), + }, + }) + + frame := m.View().Content + if strings.ContainsRune(frame, '\t') { + t.Fatal("frame still contains a raw tab; width math cannot be trusted") + } + + for i, r := range strings.Split(frame, "\n") { + if got := lipgloss.Width(r); got != 72 { + t.Errorf("row %d width %d, want 72: %q", i, got, plain(r)) + } + } +} + +// shift+enter is the binding operators reach for. A bare terminal cannot +// distinguish it from enter, so fallbacks exist — but all of them must insert a +// newline and grow the composer rather than submitting. +func TestNewlineBindingsInsertAndGrowTheComposer(t *testing.T) { + t.Parallel() + + for _, k := range []string{"shift+enter", "alt+enter", "ctrl+j"} { + t.Run(k, func(t *testing.T) { + t.Parallel() + + m, rec := newTestModel(t) + press(t, m, "a") + m.Update(key(k)) + press(t, m, "b") + + if got := m.input.Value(); got != "a\nb" { + t.Fatalf("%s: input = %q, want %q", k, got, "a\nb") + } + + if len(rec.actions) != 0 { + t.Fatalf("%s submitted instead of inserting a newline: %+v", k, rec.actions) + } + + // The composer must actually grow, and the body must shrink to + // make room for it. + if got := m.Layout().InputHeight; got != 2 { + t.Fatalf("%s: InputHeight = %d, want 2", k, got) + } + + if m.Layout().ComposerHeight != 2+panelChrome { + t.Fatalf("%s: ComposerHeight = %d, want %d", k, m.Layout().ComposerHeight, 2+panelChrome) + } + }) + } +} + +// A plain enter still submits; the newline bindings must not have swallowed it. +func TestPlainEnterStillSubmits(t *testing.T) { + t.Parallel() + + m, rec := newTestModel(t) + press(t, m, "h", "i", "enter") + + if _, ok := rec.last(t).(SubmitPrompt); !ok { + t.Fatalf("enter emitted %T, want SubmitPrompt", rec.last(t)) + } +} + +// The composer grows only to its cap, then scrolls internally rather than +// eating the whole screen. +func TestComposerGrowthIsCapped(t *testing.T) { + t.Parallel() + + m, _ := newTestModel(t) + for range 20 { + m.Update(key("shift+enter")) + } + + if got := m.Layout().InputHeight; got != inputMaxHeight { + t.Fatalf("InputHeight = %d, want the cap %d", got, inputMaxHeight) + } +} + +// The wheel must scroll this transcript. Without claiming it, the terminal +// scrolls its own scrollback behind the alt screen and the app only appears to +// have handled the gesture. +func TestWheelScrollsTheTranscript(t *testing.T) { + t.Parallel() + + m, _ := newTestModel(t) + for i := range 60 { + m.Update(placeMsg(renderv1.Region_REGION_MAIN_CHAT, fmt.Sprintf("line %d", i), uint64(i))) + } + + // Paint once so the model learns how far the content can scroll. + _ = m.View() + + if !m.pinned { + t.Fatal("expected to start pinned to the live tail") + } + + m.Update(tea.MouseWheelMsg{Button: tea.MouseWheelUp}) + + if m.pinned { + t.Fatal("scrolling up did not detach from the live tail") + } + + if m.scroll != m.maxScroll-wheelStep { + t.Fatalf("scroll = %d, want %d", m.scroll, m.maxScroll-wheelStep) + } + + // Scrolling back to the bottom re-attaches, so new content follows again. + m.Update(tea.MouseWheelMsg{Button: tea.MouseWheelDown}) + + if !m.pinned { + t.Fatal("scrolling back to the bottom did not re-attach to the live tail") + } +} + +func TestWheelDoesNotScrollPastTheTop(t *testing.T) { + t.Parallel() + + m, _ := newTestModel(t) + m.Update(placeMsg(renderv1.Region_REGION_MAIN_CHAT, "only line", 1)) + _ = m.View() + + for range 10 { + m.Update(tea.MouseWheelMsg{Button: tea.MouseWheelUp}) + } + + if m.scroll < 0 { + t.Fatalf("scroll went negative: %d", m.scroll) + } +} + +// The view must declare the takeover properties, since each of them is what +// stops some part of the terminal from behaving as if the app were not there. +func TestViewClaimsTheTerminal(t *testing.T) { + t.Parallel() + + m, _ := newTestModel(t) + m.Update(StatusMsg{Session: "session-01"}) + + v := m.View() + + if !v.AltScreen { + t.Error("AltScreen not set; the app would print into the user's scrollback") + } + + if v.MouseMode != tea.MouseModeCellMotion { + t.Error("mouse not claimed; the wheel would scroll the terminal instead of the transcript") + } + + if v.BackgroundColor == nil || v.ForegroundColor == nil { + t.Error("view did not set terminal default colors") + } + + if !strings.Contains(v.WindowTitle, "session-01") { + t.Errorf("WindowTitle = %q, want it to name the session", v.WindowTitle) + } +} + +// Before any turn there is no usage report, and the gauge must be absent +// rather than reading zero — a confident 0% is a lie. +func TestContextGaugeAbsentUntilUsageArrives(t *testing.T) { + t.Parallel() + + m, _ := newTestModel(t) + + if strings.Contains(plain(m.View().Content), "context") { + t.Fatal("context shown before any usage was reported") + } + + if _, ok := m.contextFill(); ok { + t.Fatal("contextFill claimed to know pressure with no usage") + } + + // A ceiling the kernel has not resolved is equally unknown. + m.Update(UsageMsg{UsedTokens: 100, EffectiveCeiling: 0}) + + if strings.Contains(plain(m.View().Content), "context") { + t.Fatal("context shown against a zero ceiling") + } +} + +// Pressure is measured against the effective ceiling, which is what remains +// after the kernel reserves room for output and tool schemas. +func TestContextFillDividesByTheEffectiveCeiling(t *testing.T) { + t.Parallel() + + m, _ := newTestModel(t) + m.Update(UsageMsg{UsedTokens: 50_000, EffectiveCeiling: 200_000}) + + got, ok := m.contextFill() + if !ok { + t.Fatal("pressure unknown after a usage report") + } + + if got != 0.25 { + t.Fatalf("fill = %v, want 0.25", got) + } + + if got := plain(m.View().Content); !strings.Contains(got, "25%") { + t.Fatalf("context percentage not shown:\n%s", got) + } +} + +// The gauge hue is a continuous ramp, not a set of steps: it runs green +// through amber to red so the color itself reads as pressure. +func TestContextToneRunsGreenToRed(t *testing.T) { + t.Parallel() + + m, _ := newTestModel(t) + th := m.th + + // The ramp's endpoints and midpoint land exactly on their stops. + for _, tc := range []struct { + fill float64 + want color.Color + name string + }{ + {0, th.C.Success, "empty is green"}, + {0.5, th.C.Warning, "half is amber"}, + {1, th.C.Danger, "full is red"}, + } { + if got := m.contextTone(tc.fill); got != tc.want { + t.Errorf("%s: tone at %.2f = %v, want %v", tc.name, tc.fill, got, tc.want) + } + } + + // And it moves monotonically between them: more red, less green, as + // pressure climbs. + var prevR, prevG uint32 + + for i := range 11 { + r, g, _, _ := m.contextTone(float64(i) / 10).RGBA() + + if i > 0 { + if r < prevR { + t.Errorf("red channel fell between %d0%% and %d0%%", i-1, i) + } + + if g > prevG { + t.Errorf("green channel rose between %d0%% and %d0%%", i-1, i) + } + } + + prevR, prevG = r, g + } +} + +// The warning names no command: compaction is automatic in this system, driven +// by a compactor context provider, so there is no operator action to point at. +func TestContextWarningAppearsOnlyUnderPressure(t *testing.T) { + t.Parallel() + + m, _ := newTestModel(t) + + if got := m.contextWarning(); got != "" { + t.Fatalf("warning with no usage: %q", got) + } + + m.Update(UsageMsg{UsedTokens: 100_000, EffectiveCeiling: 200_000}) + + if got := m.contextWarning(); got != "" { + t.Fatalf("warning at 50%%: %q", got) + } + + m.Update(UsageMsg{UsedTokens: 190_000, EffectiveCeiling: 200_000}) + + warning := m.contextWarning() + if warning == "" { + t.Fatal("no warning at 95% of the ceiling") + } + + if strings.Contains(warning, "/") { + t.Fatalf("warning names a command that does not exist: %q", warning) + } + + // It must reach the status line, replacing the less urgent focus label. + if got := plain(m.View().Content); !strings.Contains(got, warning) { + t.Fatalf("warning never reached the frame:\n%s", got) + } +} + +func TestContextMeterReachesTheStatusBar(t *testing.T) { + t.Parallel() + + m, _ := newTestModel(t) + m.Update(UsageMsg{UsedTokens: 51_204, EffectiveCeiling: 200_000}) + + got := plain(m.View().Content) + if !strings.Contains(got, "26%") { + t.Fatalf("context percentage not painted:\n%s", got) + } + + if !strings.Contains(got, "━") { + t.Fatalf("gauge fill not painted:\n%s", got) + } +} + +// A transcript grows upward from the composer. Content shorter than the +// viewport is pushed to the bottom so the newest message sits next to where +// the operator is typing, with the empty space above it rather than between. +func TestTranscriptIsBottomAnchored(t *testing.T) { + t.Parallel() + + m, _ := newTestModel(t) + m.Update(placeMsg(renderv1.Region_REGION_MAIN_CHAT, "newest line", 1)) + + rows := strings.Split(plain(m.View().Content), "\n") + + // Find the transcript's content rows: inside the conversation panel. + var lastContent int + + for i, r := range rows { + if strings.Contains(r, "newest line") { + lastContent = i + } + } + + if lastContent == 0 { + t.Fatal("content not found in the frame") + } + + // The composer starts within a few rows of the content, not a screen away. + composerRow := 0 + + for i, r := range rows { + if strings.Contains(r, "ask anything") { + composerRow = i + } + } + + if gap := composerRow - lastContent; gap > 4 { + t.Fatalf("content sits %d rows above the composer; it should hug it", gap) + } +} + +// Static session data belongs in the sidebar, where there is room, rather than +// beside the composer. +func TestSessionPanelsRenderInTheSidebar(t *testing.T) { + t.Parallel() + + m, _ := newTestModel(t) + m.Update(UsageMsg{UsedTokens: 10, EffectiveCeiling: 100, OutputTokens: 9_120}) + + got := plain(m.View().Content) + + for _, want := range []string{"usage", "9.1k"} { + if !strings.Contains(got, want) { + t.Errorf("sidebar missing %q:\n%s", want, got) + } + } +} + +// Where the session is working belongs in the top bar: it is stable for the +// whole session and is the first thing checked on returning to a window. +func TestWorkspaceAppearsInTheTopBar(t *testing.T) { + t.Parallel() + + m, _ := newTestModel(t) + m.Update(WorkspaceMsg{Directory: "~/code/aiagent", Repository: "pluggableharness/agent"}) + + header := headerRows(t, m) + + for _, want := range []string{"~/code/aiagent", "pluggableharness/agent"} { + if !strings.Contains(header, want) { + t.Errorf("header missing %q:\n%s", want, header) + } + } + + // The agent is a setting, not identity, and lives beside the composer. + if strings.Contains(header, "Code") { + t.Errorf("agent leaked into the header:\n%s", header) + } +} + +// statusRow returns the plain text of the volatile-state line, which sits +// between the composer and the footer box. +func statusRow(t *testing.T, m *Model) string { + t.Helper() + + rows := strings.Split(plain(m.View().Content), "\n") + + return rows[len(rows)-chromePanelHeight-1] +} + +// headerRows returns the plain text of the header box. +func headerRows(t *testing.T, m *Model) string { + t.Helper() + + rows := strings.Split(plain(m.View().Content), "\n") + + return strings.Join(rows[:chromePanelHeight], "\n") +} + +// footerRows returns the plain text of the footer box. +func footerRows(t *testing.T, m *Model) string { + t.Helper() + + rows := strings.Split(plain(m.View().Content), "\n") + + return strings.Join(rows[len(rows)-chromePanelHeight:], "\n") +} + +// A long path keeps its tail, which is the part that identifies it. +func TestLongDirectoryClipsFromTheLeft(t *testing.T) { + t.Parallel() + + m, _ := newTestModel(t) + m.Update(WorkspaceMsg{Directory: "/home/steven/code/aiagent/internal/tui/shell/deeply/nested"}) + + if header := headerRows(t, m); !strings.Contains(header, "nested") { + t.Errorf("header lost the path tail:\n%s", header) + } +} + +// A panel with nothing to show is not rendered: an empty titled box is worse +// than no box. +func TestSessionPanelsAbsentWithoutData(t *testing.T) { + t.Parallel() + + m, _ := newTestModel(t) + + if got := plain(m.View().Content); strings.Contains(got, "workspace") { + t.Fatal("workspace panel rendered with no workspace data") + } +} + +// The status line carries only what changes during a turn. +func TestStatusLineCarriesOnlyVolatileState(t *testing.T) { + t.Parallel() + + m, _ := newTestModel(t) + m.Update(WorkspaceMsg{Directory: "~/code/aiagent", Repository: "pluggableharness/agent"}) + m.Update(UsageMsg{UsedTokens: 52_000, EffectiveCeiling: 200_000, CumulativeCostUSD: 0.42, InputTokens: 100, CacheReadTokens: 900}) + m.Update(StatusMsg{Model: "claude-opus-5", Effort: "high", Elapsed: 22 * time.Minute}) + + status := statusRow(t, m) + + for _, want := range []string{"context", "26%", "cache", "cost", "$0.42", "elapsed"} { + if !strings.Contains(status, want) { + t.Errorf("status line missing %q: %q", want, status) + } + } + + // Static fields must not have followed it down here. + for _, unwanted := range []string{"dir", "repo", "model"} { + if strings.Contains(status, unwanted) { + t.Errorf("static field %q leaked onto the status line: %q", unwanted, status) + } + } + + // Model and reasoning sit on the composer, not the status line — but in the + // opposite corner from the agent, so the two are not read as one label. + rows := strings.Split(plain(m.View().Content), "\n") + prompt := composerRowIndex(t, rows) + top, bottom := rows[prompt-1], rows[prompt+1] + + for _, want := range []string{"claude-opus-5", "high"} { + if !strings.Contains(bottom, want) { + t.Errorf("composer bottom border missing %q: %q", want, bottom) + } + + if strings.Contains(top, want) { + t.Errorf("%q leaked into the composer title: %q", want, top) + } + } + + if !strings.Contains(top, "Code") { + t.Errorf("composer title missing the agent: %q", top) + } +} + +// The context meter must never blink out while a terminal is being resized. +// +// It did: the right-hand group was all-or-nothing, so one column of width could +// make a field affordable and take the meter from drawable to +// below-the-minimum in a single step. +func TestContextMeterSurvivesEveryWidth(t *testing.T) { + t.Parallel() + + // One model, resized in place: rebuilding it per width made this the + // slowest test in the package for no added coverage. + m := New() + m.Update(UsageMsg{ + UsedTokens: 51_204, EffectiveCeiling: 200_000, + CumulativeCostUSD: 0.42, InputTokens: 100, CacheReadTokens: 900, + }) + m.Update(StatusMsg{Elapsed: 22 * time.Minute}) + + // Rendering just the status line rather than the whole frame: the sidebar + // and transcript cost far more to paint and have nothing to do with this. + // The range stops at 150 deliberately: every group-shedding transition + // happens below it, and each width costs a full gradient render. + for w := 50; w <= 150; w++ { + status := plain(m.statusLine(Solve(w, 24, 1, false))) + + if !strings.Contains(status, "context") { + t.Fatalf("width %d: context segment dropped entirely: %q", w, status) + } + + if bar := strings.Count(status, "━") + strings.Count(status, "─"); bar < minMeterBar { + t.Fatalf("width %d: meter collapsed to %d cells: %q", w, bar, status) + } + } +} + +// Header and footer are bordered boxes, in the same visual language as the +// panels between them. A background tint was tried first and did not read as +// chrome at these contrast levels. +func TestHeaderAndFooterAreBoxed(t *testing.T) { + t.Parallel() + + m, _ := newTestModel(t) + m.Update(WorkspaceMsg{Directory: "~/code/aiagent"}) + + header, footer := headerRows(t, m), footerRows(t, m) + + for _, tc := range []struct{ name, rows, title string }{ + {"header", header, "pluggableharness"}, + {"footer", footer, "keys"}, + } { + if !strings.Contains(tc.rows, "╭") || !strings.Contains(tc.rows, "╰") { + t.Errorf("%s is not boxed:\n%s", tc.name, tc.rows) + } + + if !strings.Contains(tc.rows, tc.title) { + t.Errorf("%s missing its title %q:\n%s", tc.name, tc.title, tc.rows) + } + } + + // The status line between them stays unboxed: it is what the boxes are + // separating, not another piece of chrome. + rows := strings.Split(plain(m.View().Content), "\n") + status := rows[len(rows)-chromePanelHeight-1] + + if strings.Contains(status, "╭") || strings.Contains(status, "╰") { + t.Errorf("status line was boxed: %q", status) + } +} + +// The cursor must land on the row the composer actually paints, at the column +// just past the text. +// +// It drifted once already: the header grew from a single line into a bordered +// box and the cursor kept counting it as one row, so it sat two rows above the +// text. Asserting against the painted frame rather than against the arithmetic +// is what makes that class of mistake fail loudly. +func TestCursorLandsOnTheComposerRow(t *testing.T) { + t.Parallel() + + sizes := [][2]int{ + {120, 30}, {120, 24}, {120, 16}, {120, 14}, {120, 10}, {80, 20}, {60, 12}, + } + + for _, size := range sizes { + w, h := size[0], size[1] + + t.Run(fmt.Sprintf("%dx%d", w, h), func(t *testing.T) { + t.Parallel() + + m := New() + m.Update(tea.WindowSizeMsg{Width: w, Height: h}) + press(t, m, "h", "i") + + x, y, ok := m.cursorScreenPos() + if !ok { + t.Fatal("cursor reported no position while the composer had focus") + } + + rows := strings.Split(plain(m.View().Content), "\n") + if y >= len(rows) { + t.Fatalf("cursor row %d is outside the frame (%d rows)", y, len(rows)) + } + + if !strings.Contains(rows[y], "› hi") { + t.Fatalf("cursor row %d is not the composer row: %q", y, rows[y]) + } + + // The column sits immediately after what has been typed. + if got := []rune(rows[y]); x >= len(got) || string(got[x-2:x]) != "hi" { + t.Fatalf("cursor column %d does not follow the text: %q", x, rows[y]) + } + }) + } +} + +// A multi-line composer puts the cursor on the continuation row. +func TestCursorFollowsTheComposerAcrossLines(t *testing.T) { + t.Parallel() + + m, _ := newTestModel(t) + press(t, m, "a") + m.Update(key("shift+enter")) + press(t, m, "b") + + _, y, ok := m.cursorScreenPos() + if !ok { + t.Fatal("no cursor position") + } + + rows := strings.Split(plain(m.View().Content), "\n") + if !strings.Contains(rows[y], "b") || strings.Contains(rows[y], "›") { + t.Fatalf("cursor row %d is not the second composer line: %q", y, rows[y]) + } +} + +// The cursor is hidden whenever the composer does not own the keyboard. +func TestCursorHiddenWhenComposerIsNotFocused(t *testing.T) { + t.Parallel() + + m, _ := newTestModel(t) + press(t, m, "tab") // focus the transcript + + if v := m.View(); v.Cursor != nil { + t.Fatal("cursor shown while the transcript had focus") + } + + m.Update(PermissionMsg{ItemID: "i", Title: "?"}) + + if v := m.View(); v.Cursor != nil { + t.Fatal("cursor shown while a modal was up") + } +} + +// composerRow finds the row carrying the prompt, rather than assuming an +// offset that shifts whenever a band's height changes. +func composerRow(t *testing.T, rows []string) string { + t.Helper() + + return rows[composerRowIndex(t, rows)] +} + +// composerRowIndex locates the composer by its prompt rather than by counting +// rows from an edge. Offsets from the bottom of the frame have broken twice — +// once when the header became a bordered box, once when the status line grew — +// and each time the test kept passing against the wrong row for a while first. +func composerRowIndex(t *testing.T, rows []string) int { + t.Helper() + + for i, r := range rows { + if strings.Contains(r, "›") { + return i + } + } + + t.Fatal("no composer row in the frame") + + return 0 +} + +// runeCol reports the display column of the first occurrence of sub. Byte +// offsets are useless here: the box-drawing characters are multi-byte, so +// strings.Index would report a position several columns off. +func runeCol(t *testing.T, row, sub string) int { + t.Helper() + + b := strings.Index(row, sub) + if b < 0 { + t.Fatalf("%q not found in %q", sub, row) + } + + return len([]rune(row[:b])) +} + +// Every band's text starts on the same column. +// +// The status line has no border of its own, so it needs a wider margin than the +// panels to land where their content does; the lines inside the header and +// footer need a narrower one, because their panel has already padded them. +// Getting either wrong leaves a band a column adrift, which reads as sloppy +// long before anyone works out why. +func TestChromeTextSharesOneLeftMargin(t *testing.T) { + t.Parallel() + + for _, w := range []int{124, 100, 90, 72} { + m := New() + m.Update(tea.WindowSizeMsg{Width: w, Height: 26}) + m.Update(WorkspaceMsg{Directory: "~/code/aiagent"}) + m.Update(UsageMsg{UsedTokens: 51_204, EffectiveCeiling: 200_000}) + + rows := strings.Split(plain(m.View().Content), "\n") + + cols := map[string]int{ + "header": runeCol(t, rows[1], "~/code"), + "composer": runeCol(t, composerRow(t, rows), "›"), + "status": runeCol(t, rows[len(rows)-chromePanelHeight-1], "context"), + "footer": runeCol(t, rows[len(rows)-chromePanelHeight+1], "enter"), + } + + for name, got := range cols { + if got != cols["composer"] { + t.Errorf("width %d: %s starts at column %d, composer at %d", w, name, got, cols["composer"]) + } + } + } +} + +// The status line's right edge lands on the panels' content edge, not on their +// border — it is inset to match what is inside them. +func TestStatusLineRightEdgeMatchesPanelContent(t *testing.T) { + t.Parallel() + + m, _ := newTestModel(t) + m.Update(UsageMsg{ + UsedTokens: 51_204, EffectiveCeiling: 200_000, + CumulativeCostUSD: 0.42, InputTokens: 100, CacheReadTokens: 900, + }) + m.Update(StatusMsg{Elapsed: 22 * time.Minute}) + + rows := strings.Split(plain(m.View().Content), "\n") + status := strings.TrimRight(rows[len(rows)-chromePanelHeight-1], " ") + composer := strings.TrimRight(composerRow(t, rows), " ") + + // The composer row ends with gutter + border; its content ends two columns + // earlier, which is where the status line should end. + statusEnd := len([]rune(status)) - 1 + composerContentEnd := len([]rune(composer)) - 1 - 2 + + if statusEnd != composerContentEnd { + t.Errorf("status ends at column %d, panel content ends at %d", statusEnd, composerContentEnd) + } +} From 65872090f98a85c892f3db85ee5d1922f874ee71 Mon Sep 17 00:00:00 2001 From: Steven Crothers Date: Mon, 27 Jul 2026 10:05:03 -0400 Subject: [PATCH 06/11] cmd/tui: add the terminal shell binary Thin wiring: flags, a stderr slog handler, the terminal, the program. It opens /dev/tty (CONOUT$ on Windows) and passes it as both input and output rather than using the process stdio. As a go-plugin subprocess the shell's stdout carries the handshake and is piped into the host's logger, so painting there corrupts the handshake and reading stdin competes with the plugin transport. The build-tagged openTTY is the whole reason this binary is not three lines. Currently driven by the scripted demo source, since no kernel-side frontend-attach path exists to drive it for real yet. --- cmd/tui/main.go | 131 +++++++++++++++++++++++++++++++++++++++++ cmd/tui/tty_unix.go | 16 +++++ cmd/tui/tty_windows.go | 17 ++++++ 3 files changed, 164 insertions(+) create mode 100644 cmd/tui/main.go create mode 100644 cmd/tui/tty_unix.go create mode 100644 cmd/tui/tty_windows.go diff --git a/cmd/tui/main.go b/cmd/tui/main.go new file mode 100644 index 0000000..03d75b1 --- /dev/null +++ b/cmd/tui/main.go @@ -0,0 +1,131 @@ +// Command tui runs the reference terminal shell for PluggableHarness Agent. +// +// The shell is a frontend provider: in its finished form the kernel launches it +// as a hashicorp/go-plugin subprocess and drives it over a bidirectional Attach +// stream. That kernel-side attach path does not exist yet, so this binary +// currently runs the shell against a scripted demo source, which is what makes +// the layout, focus model, and keymap reviewable ahead of the wiring. +// +// The terminal is opened directly rather than using stdin/stdout, because under +// go-plugin those streams belong to the handshake and the host's logger. That +// is the real code path, exercised here so it does not need revisiting when the +// bridge lands. +package main + +import ( + "context" + "flag" + "fmt" + "log/slog" + "os" + "os/signal" + "syscall" + "time" + + tea "charm.land/bubbletea/v2" + + "github.com/pluggableharness/agent/internal/tui/shell" + "github.com/pluggableharness/agent/internal/tui/theme" +) + +func main() { + themeName := flag.String("theme", "dark", "color theme: dark or light") + step := flag.Duration("step", 120*time.Millisecond, "delay between scripted demo events") + logLevel := flag.String("log-level", "warn", "log level: debug, info, warn, error") + flag.Parse() + + if err := run(*themeName, *step, *logLevel); err != nil { + // Diagnostics go to stderr, never to the painted surface. Under + // go-plugin the host collects this as structured plugin output. + fmt.Fprintf(os.Stderr, "tui: %v\n", err) + os.Exit(1) + } +} + +func run(themeName string, step time.Duration, logLevel string) error { + slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{ + Level: parseLevel(logLevel), + }))) + + th, ok := theme.ByName(themeName) + if !ok { + slog.Warn("unknown theme, falling back", "requested", themeName, "using", th.Name) + } + + tty, err := openTTY() + if err != nil { + // No controlling terminal: the shell degrades to not attaching rather + // than taking down whatever launched it. + return fmt.Errorf("tui: open terminal: %w", err) + } + defer func() { + if cerr := tty.Close(); cerr != nil { + slog.Warn("closing terminal", "error", cerr) + } + }() + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + outbox := make(chan shell.Action, 64) + model := shell.New( + shell.WithTheme(th), + shell.WithEmitter(func(a shell.Action) { + select { + case outbox <- a: + default: + slog.Warn("outbox full, dropping action") + } + }), + ) + + // Alt-screen is declared by the model's View in Bubble Tea v2, not as a + // program option. + prog := tea.NewProgram(model, + tea.WithContext(ctx), + tea.WithInput(tty), + tea.WithOutput(tty), + ) + + go drainOutbox(ctx, outbox) + go func() { + src := shell.DemoSource{Step: step} + if rerr := src.Run(ctx, prog.Send); rerr != nil { + slog.Error("event source stopped", "error", rerr) + } + }() + + if _, err := prog.Run(); err != nil { + return fmt.Errorf("tui: run: %w", err) + } + + return nil +} + +// drainOutbox stands in for the Attach stream's writer goroutine. The real +// bridge translates each Action into a ClientEvent and writes it to the stream +// in arrival order, which matters because the kernel processes client events in +// arrival order per session. +func drainOutbox(ctx context.Context, outbox <-chan shell.Action) { + for { + select { + case <-ctx.Done(): + return + case a := <-outbox: + slog.Debug("client action", "action", fmt.Sprintf("%T", a)) + } + } +} + +func parseLevel(s string) slog.Level { + switch s { + case "debug": + return slog.LevelDebug + case "info": + return slog.LevelInfo + case "error": + return slog.LevelError + default: + return slog.LevelWarn + } +} diff --git a/cmd/tui/tty_unix.go b/cmd/tui/tty_unix.go new file mode 100644 index 0000000..50656fb --- /dev/null +++ b/cmd/tui/tty_unix.go @@ -0,0 +1,16 @@ +//go:build !windows + +package main + +import "os" + +// openTTY opens the controlling terminal for direct read/write. +// +// The shell must never render to stdout or read stdin: when it runs as a +// go-plugin subprocess the handshake line is written to stdout and the host +// pipes stdout and stderr into its own logger, so painting there would corrupt +// the handshake and reading there would compete with the plugin transport. +// Opening the controlling terminal sidesteps both. +func openTTY() (*os.File, error) { + return os.OpenFile("/dev/tty", os.O_RDWR, 0) +} diff --git a/cmd/tui/tty_windows.go b/cmd/tui/tty_windows.go new file mode 100644 index 0000000..6418ea7 --- /dev/null +++ b/cmd/tui/tty_windows.go @@ -0,0 +1,17 @@ +//go:build windows + +package main + +import "os" + +// openTTY opens the Windows console device for direct read/write. +// +// This is the Windows half of the same constraint the unix build documents: +// stdout carries the go-plugin handshake and is piped into the host's logger, +// so the shell paints to the console device instead. CONIN$/CONOUT$ are the +// console equivalents of /dev/tty, but they are two separate handles rather +// than one bidirectional file, so the caller receives the output handle and +// Bubble Tea opens console input itself. +func openTTY() (*os.File, error) { + return os.OpenFile("CONOUT$", os.O_RDWR, 0) +} From 265c436ac51f26c5c6ccf2b8dae5c447c61948e7 Mon Sep 17 00:00:00 2001 From: Steven Crothers Date: Mon, 27 Jul 2026 10:05:03 -0400 Subject: [PATCH 07/11] docs: document the terminal shell design MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds docs/first-party/frontends/tui.md — process shape and TTY ownership, the design system, screen layout, where session data lives and why, keymap layers, and what is deliberately deferred. It is descriptive, not normative. The frontend spec leaves focus, keybindings, resize, and scrollback to each frontend on purpose, and this documents one implementation's choices rather than adding requirements to the protocol. Registers it in the mkdocs nav and llmstxt sections, and notes the shell's current state in the project CLAUDE.md. --- CLAUDE.md | 4 +- docs/first-party/frontends/README.md | 16 ++ docs/first-party/frontends/tui.md | 399 +++++++++++++++++++++++++++ docs/first-party/index.md | 3 +- mkdocs.yml | 4 + 5 files changed, 424 insertions(+), 2 deletions(-) create mode 100644 docs/first-party/frontends/README.md create mode 100644 docs/first-party/frontends/tui.md diff --git a/CLAUDE.md b/CLAUDE.md index a3e6ec5..5e56b31 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,7 +19,9 @@ An AI coding harness built as a Go microkernel: the kernel owns plugin lifecycle ## Current state -Kernel-side packages in `internal/` and the `pkg/` SDK are real, tested Go. There is no `cmd/` binary yet, and most plugin categories exist only as spec. Implementation is spec-first: before writing code, confirm the relevant spec exists and is settled; if it has open questions bearing on the task, raise them instead of coding against an assumption. Don't start new implementation work without being asked. +Kernel-side packages in `internal/` and the `pkg/` SDK are real, tested Go. Three `cmd/` binaries exist: `agent` (the kernel, currently non-interactive — no REPL), `anthropic` (the reference model-provider plugin, and the template for any new plugin binary), and `tui` (the reference terminal shell, currently driven by a scripted demo source because no kernel-side frontend-attach path exists yet). Most other plugin categories exist only as spec. Implementation is spec-first: before writing code, confirm the relevant spec exists and is settled; if it has open questions bearing on the task, raise them instead of coding against an assumption. Don't start new implementation work without being asked. + +The terminal shell's design — region layout, focus model, keymap layers, and the TTY-ownership constraint that follows from a frontend being a go-plugin subprocess — is [`docs/first-party/frontends/tui.md`](docs/first-party/frontends/tui.md). It is descriptive, not normative: the protocol deliberately leaves focus, keybindings, resize, and scrollback to each frontend. ## Toolchain, testing, and CI diff --git a/docs/first-party/frontends/README.md b/docs/first-party/frontends/README.md new file mode 100644 index 0000000..1cecd43 --- /dev/null +++ b/docs/first-party/frontends/README.md @@ -0,0 +1,16 @@ +# Frontend implementations — index + +This directory contains first-party reference documentation for frontend providers — the plugin category that owns the display surface, paints [`RenderTree`](../../specifications/frontend/render-tree.md) content into the region vocabulary, and turns operator input into `ClientEvent`s. + +> [!IMPORTANT] +> These are descriptive design documents, not the protocol spec itself: the frontend and widget provider protocols, the `RenderTree` intermediate representation, and the region/placement model live in [`docs/specifications/frontend/`](../../specifications/frontend/README.md), which remains the source of truth. A frontend's layout, focus model, and keymap are explicitly *not* protocol — the spec's own reference-TUI table is labelled non-normative — so everything documented here is one conforming implementation's choices, not a constraint on any other frontend. + +## Implementations + +| Frontend | Document | Surface | +|---|---|---| +| Reference TUI shell | [tui.md](tui.md) | Full-screen terminal, Bubble Tea + Lip Gloss | + +## Why these documents exist + +The protocol deliberately defines *what* content arrives and *where* it is placed, and stops there — it specifies no focus model, no keybinding schema, no resize semantics, and no scrollback behavior. Those are display concerns that only a concrete surface can answer, and they must be answered before widget plugins are written: a widget contributing to `sidebar` needs to know whether that region can hold focus and how its `ActionNode`s become reachable from the keyboard. Documenting each frontend's resolution of those gaps is what gives integrations a stable target to attach to. diff --git a/docs/first-party/frontends/tui.md b/docs/first-party/frontends/tui.md new file mode 100644 index 0000000..5f6c3e1 --- /dev/null +++ b/docs/first-party/frontends/tui.md @@ -0,0 +1,399 @@ +# The reference TUI shell + +The first-party frontend provider: a full-screen terminal shell that paints [`RenderTree`](../../specifications/frontend/render-tree.md) content into the six-region vocabulary and turns operator input into [`ClientEvent`](../../specifications/frontend/frontend-protocol.md#client-events)s. + +> [!IMPORTANT] +> This document is descriptive, not normative. The protocol contract — what any frontend MUST implement — is [`frontend-protocol.md`](../../specifications/frontend/frontend-protocol.md) and [`render-tree.md`](../../specifications/frontend/render-tree.md). The layout, focus model, and keymap described here are *this* shell's choices, one conforming instantiation of the abstract region vocabulary, exactly as [`examples.md#the-reference-tui`](../../specifications/frontend/examples.md#the-reference-tui) frames it. A second frontend is free to resolve every one of them differently. + +## Why a shell framework exists at all + +The protocol defines *what* content arrives and *where* it is placed, and deliberately stops there. It defines no focus model, no keybinding schema, no terminal-resize semantics, and no scrollback behavior — [`conformance.md`](../../specifications/frontend/conformance.md) is explicit that the region vocabulary was designed against one reference implementation plus a thought experiment. Those four gaps are not oversights to be pushed back into the protocol; they are display concerns that only a concrete surface can answer. This document answers them for the terminal, so that widget authors and future integrations have stable, named places to attach to. + +The ordering matters: the shell's regions, focus ring, and keymap layers must exist before widget plugins are written, because a widget contributing to `sidebar` needs to know whether `sidebar` can hold focus, whether its `ActionNode`s are reachable from the keyboard, and what happens to it in a narrow terminal. + +## Process shape — who owns the terminal + +A frontend is a `hashicorp/go-plugin` subprocess, per [`plugin-runtime`](../../specifications/frontend/README.md#transport--lifecycle): the kernel is the gRPC *client*, the shell is the *server*, and the kernel calls `Attach` on it. That inverts the usual intuition — the process painting the screen is the child, not the parent — and it creates the single most load-bearing constraint in this design. + +**go-plugin owns the subprocess's standard streams.** The handshake line is written to the plugin's `stdout`, and after handshake the host pipes the plugin's `stdout`/`stderr` into its own logger. A TUI that renders to `stdout` therefore corrupts the handshake, and one that reads `stdin` competes with the plugin transport. + +The shell resolves this by never touching the standard streams for display: it opens the controlling terminal directly and hands that file to Bubble Tea as both input and output. + +```go +tty, err := os.OpenFile("/dev/tty", os.O_RDWR, 0) +// ... +prog := tea.NewProgram(model, + tea.WithInput(tty), + tea.WithOutput(tty), + tea.WithContext(ctx), +) +``` + +Note there is no `tea.WithAltScreen()`: in Bubble Tea v2 the alt screen is a property of the `View` the model returns, not a program option. + +Consequences that follow from this and are not negotiable: + +- `stdout` and `stderr` remain go-plugin's. Nothing in the shell may `fmt.Println`. Diagnostics go through `slog` (which the host collects from `stderr` as structured plugin logs) or the kernel's `Log` callback — never to the painted surface. +- The shell requires a controlling terminal. Launched without one (CI, a daemonized kernel, a piped session), opening `/dev/tty` fails and the shell MUST degrade to a non-painting mode rather than crash the kernel that spawned it — it reports `FRONTEND_ERROR_CATEGORY_UNKNOWN` at `Configure` time and declines to attach. +- Windows uses `CONIN$`/`CONOUT$` in place of `/dev/tty`; this is the one genuinely platform-forked file in the tree, isolated behind a build-tagged `openTTY()` so nothing else needs to care. + +### Owning the whole screen + +Painting to the right file is only half of taking over a terminal. The shell also claims the surface itself, through properties of the `View` it returns each frame: + +| Property | Effect | +|---|---| +| `AltScreen` | Full-screen takeover, leaving the user's scrollback untouched on exit | +| `BackgroundColor` / `ForegroundColor` | Sets the terminal's own default colors to the theme's, so nothing shows through as "not part of the app" | +| `WindowTitle` | Names the session in the terminal's title bar | +| `Cursor` | Places the **real** terminal cursor in the composer, colored and blinking, and hides it whenever the composer does not own the keyboard | +| `MouseMode` | Claims the mouse, so the wheel scrolls the transcript instead of the terminal's own scrollback behind the alt screen | + +Claiming the mouse is what closes the last gap in the takeover. Without it the wheel still appears to work — but it is scrolling the terminal's buffer *behind* the alt screen, not the conversation. The cost is that drag-selection now needs the terminal's own override, which is shift+drag in most of them. + +The cursor point is worth stating plainly: the composer draws no caret glyph of its own. It reports its buffer position, the shell maps that onto absolute screen coordinates, and Bubble Tea places the actual cursor there — so it behaves exactly like the cursor in every other terminal application rather than being a drawn approximation of one. + +The last piece is the invariant that ties the frame together: **every frame is exactly `Height` rows of exactly `Width` cells**. An uncovered cell shows the terminal's own background and breaks the illusion; a row wider than the terminal wraps and shifts everything beneath it. This is asserted by test across a range of sizes down to 20×6, because it is otherwise only visible by eye. + +## The design system + +Before the layout, the vocabulary it is built from. Without a constrained token set, every pane picks its own padding, its own border color, and its own idea of what "muted" means, and the result is a collection of individually reasonable choices that does not look like one system. The shell therefore borrows the structure of a utility-first CSS framework, in two layers. + +**Layer one: raw palette.** `theme.Palette` is the literal color values, named for what they are — a ten-step neutral ramp from app background to strongest text, plus six intent hues. Nothing outside the theme package references it. + +**Layer two: semantic tokens.** `theme.Tokens` names every color by its *role*, and it is the only color vocabulary the rest of the shell sees: + +| Group | Tokens | +|---|---| +| Surfaces | `Background` (the application surface), `BackgroundPanel`, `BackgroundElement` | +| Text | `Text`, `TextMuted`, `TextSubtle`, `OnAccent` | +| Borders | `BorderSubtle`, `Border`, `BorderActive` | +| Intents | `Primary`, `Accent`, `Success`, `Warning`, `Danger`, `Info` | +| Diff | `DiffAdded`, `DiffRemoved`, `DiffContext`, `DiffHunkHeader` | + +The non-color half matters just as much: spacing is a scale (`Space0`..`Space4`, plus `Gutter`), and borders are a preset set. A pane that wants more padding picks the next step; it does not invent `3`. + +### One surface, and why + +**What makes the UI read as paneled is borders, titles, and spacing — not competing background colors.** The shell paints a single application surface, set once via the Bubble Tea `View`, and content styles carry a foreground and nothing else. + +This is a correctness rule before it is a taste one. Lip Gloss terminates every styled run with a full SGR reset, and a reset inside a container clears the background that container set. A text style carrying its own background therefore paints a band that stops exactly where the text stops, dropping the rest of the row back to the terminal's background: + +``` +ESC[48;2;21;25;34m <- container opens its background + ESC[38;2;158;206;106;…m styled ESC[m <- inner run ends with a FULL reset + " PADDING" <- so this lands on the terminal background +ESC[m +``` + +Filling broad regions is therefore unreliable in a way no amount of care at the call site fixes, and the visible result is a patchwork of bands at differing widths across every pane. `BackgroundPanel` and `BackgroundElement` remain in the token set, but they are for genuinely filled, self-contained controls — a status badge, a focused button — where the run opens and closes its own fill and cannot bleed. A test asserts that no content style sets a background. + +**Utilities and components.** `internal/tui/ui` is the terminal analogue of utility classes: `Style` is a chainable builder where each method sets exactly one property, so a pane's appearance reads as a sentence where it is used — `ui.New().Bg(t.C.BackgroundPanel).Fg(t.C.TextMuted).Px(theme.Space1)`. `Panel`, `Bar`, and `Badge` are compositions of those utilities, not escapes from them. + +Two cell-accuracy rules fall out of this and are load-bearing rather than cosmetic. Every component covers **every cell it claims**, because an uncovered cell shows the terminal's own background and breaks the illusion of a full-screen application. And **tabs are expanded to spaces on the way in**: a tab measures as zero cells but a terminal advances to the next tab stop when it draws one, so an unexpanded tab in producer content paints wider than it measures, overflows its pane, and corrupts every row to its right. + +## Screen layout + +Six abstract regions map onto terminal geometry as follows. This is the wide layout, at or above 100 columns: + +``` + pluggableharness session-01ABC claude-opus-5 ready <- top_bar + ╭─ conversation ────────────────────────────────╮ ╭─ git ──────────────╮ + │ Reference TUI shell │ │ branch: main │ + │ ▾ read_file(internal/tui/shell/model.go) │ │ 3 modified │ <- sidebar: + │ func (m *Model) View() tea.View { │ │ [ Review diff ] │ one panel + │ @@ -12,3 +12,4 @@ │ ╰────────────────────╯ per widget + │ - return Layout{} │ ╭─ context ──────────╮ + │ + l := Layout{Width: width} │ │ 42% of budget │ + │ [ Compact context ] │ ╰────────────────────╯ + ╰───────────────────────────────────────────────╯ + ╭─ message ───────────────────────────────────────────────────────────╮ <- input_bar + │ › ask anything, or / for commands │ + ╰─────────────────────────────────────────────────────────────────────╯ + enter send · alt+enter newline · tab focus · ctrl+c interrupt input <- hotkey_hints + +overlay: a centered pane composited over the frame, on the element surface + with an active border — never inline, and never blanking what is + behind it. See "Overlay is modal" below. +``` + +Every region is a panel or a bar, and each carries a title in its top border rather than on a row of its own — that buys back a line of content per pane, which is what makes a stack of small side panels affordable. + +A panel may also carry a **caption** in its bottom border, against the right corner. It is for the second thing a panel often has to say about itself: not what it is, but how it is currently configured. Splitting the two across the diagonal gives each a corner to own, so neither has to be found inside a long run of text. The caption is never clipped — an abbreviated model name names no model — so it renders whole or drops out entirely on a narrow terminal. + +**The sidebar is one panel per contributing producer, not one column of concatenated text.** This is the affordance widget authors design against: a titled panel makes it obvious which plugin contributed what, and lets a single widget be focused and act on without its neighbors coming along. + +Layout is solved top-down in a fixed order so it stays deterministic: `top_bar` takes 1 row, the composer takes its measured content height (clamped to 6) plus its border, `hotkey_hints` takes 1 row, and the body receives every row left over. Horizontally, a one-cell gutter runs down each screen edge, `sidebar` is measured next (clamped to `[26, 38]` columns and never more than 40% of the terminal), and the main panel takes the remainder. `Layout` exposes only *outer* boxes; interior sizes come from its `Inner` helpers so no caller open-codes the border-and-padding arithmetic. + +### Responsive degradation + +The protocol's graceful-fallback rule ([`render-tree.md:136`](../../specifications/frontend/render-tree.md)) says placement is a hint the frontend may reinterpret, never a mandate. This shell reinterprets in a fixed, documented order as space runs out, so behavior is predictable rather than emergent: + +| Constraint | Response | +|---|---| +| width `< 100` | `sidebar` leaves the layout and becomes a toggleable pane (`ctrl+b`). Its content is not dropped — it is reachable on demand. | +| width `< 64` | `sidebar` is unavailable entirely; its content folds into `main_chat`, per the spec's "fold it into another region" allowance. | +| height `< 12` | `hotkey_hints` is dropped first (it is a reminder, not content). | +| height `< 10` | `top_bar` is dropped; `main_chat` and `input_bar` are the last two regions standing. | +| any | `input_bar` and `main_chat` are never dropped. A shell that cannot show input or output is not a shell. | + +Dropping a region is logged once per transition at `debug`, never per frame, and never surfaces as `region_unsupported` to the kernel — the region is supported, the terminal is merely small, and the protocol's own open question notes those two conditions currently share one error category. The shell therefore reports neither and just adapts. + +## The content store + +Regions do not hold a tree; they hold an ordered set of *placements*, because multiple producers may target one region and coexistence — not exclusivity — is the documented default. + +```go +type producerKey struct{ category, name string } // server-derived identity +type placement struct { + producer producerKey + priority int32 + ranked bool // false == "unset", which sorts after every ranked entry + seq uint64 // kernel sequence; the sole tiebreak + tree *renderv1.RenderTree +} +``` + +Two placement behaviors, selected by `PlacedContent.replace`: + +- **Append** (`main_chat`'s default): the placement is added to the region's transcript and never rewrites prior entries. This is the conversation flow. +- **Replace**: the placement supersedes *that producer's* prior entry in that region, leaving other producers' entries untouched. This is how a status widget updates without evicting its neighbors. + +Ordering is `(ranked, priority, seq)` ascending — unset priority sorts last, and `seq` is the only tiebreak. **Wall-clock time is never an input to ordering, and the store is never iterated as a map**, both of which the repository's determinism rules require. The practical effect is that two shells replaying the same session paint identical frames. + +Rendered output is derived state: it is recomputed from the store, never persisted, never cached to disk. + +### Streaming text + +`stream_delta` is the fast path and never round-trips through `Render`. The store keeps a live buffer keyed by `target_id`; consecutive deltas append to it and it paints as ordinary `main_chat` content. When the corresponding finished `render` arrives, it *replaces* the buffer rather than appending beside it — otherwise every streamed message would appear twice. Backfill never contains deltas, so the replay path exercises none of this. + +## Focus — the first gap the protocol leaves open + +Focus is a shell concept; the protocol has no notion of it. The model here is deliberately small. + +**Focus targets are regions, not nodes.** The ring is `input_bar → main_chat → sidebar → input_bar`, cycled with `tab` / `shift+tab`. `input_bar` holds focus at startup, because typing is the overwhelmingly common intent and a shell that requires a keystroke before it accepts text is hostile. + +**Within a focused region, an action cursor selects among that region's `ActionNode`s.** The protocol requires every `ActionNode` be interactive and that activation dispatch `action_trigger` with `tool_name`/`args`/`provider` unchanged. This shell satisfies that by assigning each actionable node a stable index in paint order; `↑`/`↓` move the cursor within the focused region and `enter` activates. Regions with no actionable nodes are skipped by the focus ring entirely, so `tab` never lands somewhere inert. + +**Overlay is modal and exclusive.** When overlay content exists, it takes focus unconditionally, the focus ring is suspended, and the previously focused region is restored when the overlay clears. This is what makes a plan-approval prompt un-missable, and it is the shell's answer to the protocol's requirement that overlay content be *visually distinct* from ambient content — here it is also behaviorally distinct. + +## Agents + +The shell carries an active **agent** — the profile a turn will run under — and `shift+tab` cycles it, which is the convention operators arrive with from other harnesses. The demo roster is three entries: + +| Agent | Tone | Meaning | +|---|---|---| +| `Code` | `primary` | Build and edit — the ordinary mode | +| `Plan` | `warning` | Read-only; nothing gets applied | +| `Chat` | `info` | Conversation only | + +**An agent is data, not code.** `Agent` is a name plus a *tone*, and the roster is expected to come from configuration — an `agent_profile` block naming a color — so nothing in the shell may assume the three built-ins exist. `WithAgents` replaces the roster wholesale; an empty one keeps the defaults, because the shell always needs something to display as active. + +**The color is a role, not a value.** Config says `color = "warning"`, `theme.ToneByName` resolves it, and the active theme decides what amber actually is. That is what keeps a custom theme able to recolor agents along with everything else, and it is why agents never name a hex value. + +The active agent is visible in three places, so the current mode is never a guess: a filled badge in the top bar, the composer's title, and the composer's border and prompt caret, both drawn in the agent's tone. The agent's tone stops at the title — the model caption in the opposite corner stays neutral, because the model is not what the agent switch recolors. Color alone is never the only signal — the name appears twice in text. + +> [!NOTE] +> **This selection is local state, and that is a protocol gap rather than a design choice.** The frontend protocol's `ClientEvent` set has no agent-profile variant, and a session's profile is fixed when the session is created — so there is currently no way to tell the kernel that the operator switched. The shell emits an `AgentSelected` action for the bridge to interpret, most plausibly as the profile for the *next* session or as a direct-invoke slash command. Resolving it properly means either a new `ClientEvent` variant or an explicit statement that agent switching applies at session-creation time only. That belongs in [`frontend-protocol.md`](../../specifications/frontend/frontend-protocol.md), not here. + +Cycling is a global binding, so it works from any focused region; it does not disturb focus, and `shift+tab` no longer moves focus backward. The focus ring is at most three entries, so cycling forward with `tab` reaches everything and a backward binding was not worth the key. `KeyMap.PrevFocus` keeps its field, unbound, for a future configuration. + +## Where session data lives + +Session data is placed by **how fast it changes**, not by what kind of thing it is. That single rule decides the whole layout: + +| Volatility | Data | Home | +|---|---|---| +| Where the work is | directory, repository | Header box | +| Session identity | session, run state | Header box, right | +| Who you are talking to | agent | Composer title | +| What is behind it | model, thinking, effort | Composer caption, bottom right | +| Detail, consulted occasionally | token split, lines read and changed | Sidebar — `usage` panel | +| **Volatile, changes every turn** | context, cache rate, cost, elapsed | One line under the composer | + +The reasoning is about attention rather than tidiness. Space beside the input box is the most-looked-at real estate on the screen, so it goes to the things that actually move. A field that is identical on turn one and turn fifty has not earned a place there — it belongs in the periphery, which is exactly where the empty space was. + +Two placements deserve their reasons stated. **The agent and the model both live on the composer, but in opposite corners.** Both belong beside the input — switching agent is what changes the model behind it, so separating them across the screen would make one change appear in two distant places. But they answer different questions and move at different rates: the agent is who you are talking to and changes on a keystroke, while the model is what is behind it and changes rarely. Run together in one title they became a four-part string in which neither was findable, and the agent's color bled onto settings it does not own. The agent takes the title, where the eye already goes for a panel's name; the model takes the caption, quiet until looked for. **Version-control detail is absent from shell chrome entirely:** a git widget already contributes branch and PR as ordinary sidebar content, and carrying them in the status bar too would give the operator two sources for one truth. Only the directory and repository — which the shell is told at startup and which never change — sit in the top bar. + +``` + ╭─ pluggableharness ───────────────────────────────────────────────────────────────────╮ + │ ~/code/aiagent │ pluggableharness/agent session-01DEMO [ready] │ + ╰──────────────────────────────────────────────────────────────────────────────────────╯ + ╭─ conversation ──────────────────────────────────╮ ╭─ usage ───────────────────────────╮ + │ │ │ out 9.1k │ + │ (empty space lives up here) │ │ in 165.2k │ + │ │ │ lines 4.8k read +612 -148 │ + │ Reference TUI shell │ ╰───────────────────────────────────╯ + │ ▾ read_file(internal/tui/shell/model.go) │ ╭─ git ─────────────────────────────╮ + │ @@ -12,3 +12,4 @@ │ │ feat/tui-shell │ + │ - return Layout{} │ │ 3 modified │ + │ + l := Layout{Width: width} │ │ pr #11 │ + │ [ Compact context ] │ │ [ Review diff ] │ + │ Streaming text arrives token by token. │ ╰───────────────────────────────────╯ + ╰─────────────────────────────────────────────────╯ + ╭─ Code · claude-opus-5 · extended · high ───────────────────────────────────────╮ + │ › ask anything, or / for commands │ + ╰──────────────────────────────────────────────────────────────────────────────────────╯ + context ━━━━━━━━──────────── 51.2k / 200k 26% │ cache 89% │ cost $0.42 │ elapsed 22m00s + ╭─ keys ───────────────────────────────────────────────────────────────────────────────╮ + │ enter send · shift+enter newline · shift+tab agent · tab focus input │ + ╰──────────────────────────────────────────────────────────────────────────────────────╯ +``` + +### Header and footer are boxes + +They are bordered panels, in the same visual language as everything between them, each carrying a title — the product name above, `keys` below. + +A background tint was tried first and does not work. At the contrast levels a dark theme lives at, a tinted row reads as a slightly-off *content* row rather than as a frame; the eye needs an edge, not a shade. A box gives it one for the cost of two rows. Those rows are real, so the header and footer are dropped outright on a short terminal rather than degrading to unboxed lines — one visual language is worth more than one extra row of transcript. + +The status line deliberately stays **unboxed** between the composer and the footer. It is what the two boxes are separating; boxing it too would leave three stacked frames and nothing to separate. + +### The transcript grows upward + +Content shorter than the viewport is pushed to the **bottom** of its panel, not left at the top. Every chat interface works this way, and the reason is the same here: the newest message belongs next to where the operator is typing. Top-anchoring instead strands the last message a screen away from the input and puts the empty space *between* the two things you are looking at — which is the worst possible place for it. Anchored to the bottom, the void sits above the conversation where it costs nothing. + +### The sidebar is the session dashboard + +Panels stack from the top: the shell's own `workspace` and `usage` first, then whatever widgets have contributed. They are rendered identically on purpose — a widget author looking at the sidebar should see one visual language, not shell chrome sitting apart from plugin content. + +A panel with no data is not rendered at all. An empty titled box is worse than no box. + +### The status line + +One row, and only what changes during a turn. **The context meter is the whole left side and grows into whatever the right group leaves** — cache, cost, and elapsed are pinned right, and the meter absorbs everything between. Context is alone on the left for a reason: a line drops left segments right-to-left, so anything beside it would survive at its expense, and context is the field worth keeping longest. + +The absolute figures ride immediately after the bar (`51.2k / 200k 26%`) rather than at the far edge. That placement is what lets the bar grow without the earlier failure mode, where a long meter ended in a percentage marooned halfway across the screen. + +**The bar is a gradient, not a single color.** It runs green through amber to red along its length, with the consumed run in full color and the remainder in a muted blend of the same gradient. A uniformly-amber bar tells you the current state; a gradient shows you the whole scale and where on it you sit. It remains a heavy stroke against a light one, so the measurement still reads on a monochrome terminal and never depends on telling green from red. + +The blend happens in **Oklab**, not in sRGB. Interpolating sRGB channels is the obvious implementation and it looks wrong: green to red passes through a muddy olive, the midpoint is darker than either end, and the result bands visibly because equal numeric steps are not equal perceptual steps. + +**The meter never blinks out while a terminal is resized.** It used to: the right-hand group was all-or-nothing, so a single column of width could make a field affordable and take the meter from drawable to below-its-minimum in one step. The right group now sheds one field at a time, and the segment reserves room for a usable bar rather than only for its text. + +Each line has a left and a right group so it spans its width. The right group sheds fields from its own right end until the left fits beside it whole — reserving its width first would let a secondary field evict a primary one, inverting the ranking that segment order expresses. A line with an empty left group still renders its right: the two are independent. + +When a filling segment has taken every spare cell, the space between the two groups is exactly one separator wide, because that is what was reserved for it — so the separator is drawn there. Leaving it blank produced a conspicuous hole with no divider to explain it. Without a filling segment the gap is genuine slack pushing the right group to the edge, and a divider stranded in the middle of it would only look lost. + +Key hints drop **whole bindings** from the end rather than being cut to width: a hint truncated mid-word reads as a rendering fault and tells the operator nothing. + +### Paths clip from the left + +The directory in the top bar keeps its tail. Given too little room, `…/aiagent/internal/tui` tells you where you are and `/home/steven/code/…` does not — so `ui.ClipLeft` cuts from whichever end preserves the part that identifies the thing. + +### Measuring context against the right number + +The denominator is `UsageUpdate.effective_ceiling`, not the model's raw `context_window`. The ceiling is what remains after the kernel reserves room for expected output and tool schemas, and the protocol names that pair as the one "a context-budget indicator divides to get pressure". Dividing by the raw window would understate pressure — you would read 70% while the next turn was already at risk of not fitting. + +**Unknown is not zero.** Before the first `UsageUpdate` the context segment is absent, and the same holds for a ceiling the kernel has not resolved. A meter confidently reading 0% before any turn has run is a lie that looks like a fact. + +Past 85% the hint line replaces the focus label with a plain warning. It names no command: compaction here is automatic — a context provider declaring `compactor: true` receives the conversation history and returns a rewritten one on its own initiative ([`context/protocol.md#session-wide-conversation-compaction`](../../specifications/context/protocol.md#session-wide-conversation-compaction)) — so there is no operator-invoked `/compact` to point at. + +### What the protocol actually supplies + +Only some of this has a wire source. The rest arrives as shell messages, which is deliberate: the shell performs no I/O, so anything it cannot be *told* it cannot know. + +| Field | Source | +|---|---| +| model, session, status | `ServerEvent` session lifecycle | +| context, cost, token split, cache rate | `UsageUpdate`, including the per-turn `model.v1.Usage` | +| effort, session length | `ModelSpec.thinking` and `SessionInfo.started_at` exist, but no frontend event carries them; the bridge resolves and passes them through | +| directory, repository | **No protocol source, by design.** There is no workspace concept in the wire contracts and none should be invented for a status bar; `cmd/tui` supplies these | +| branch, subtree, PR | Not rendered by the shell at all — a git widget contributes them as ordinary sidebar content, so there is one source for one truth | +| lines read and changed | **No protocol source.** No event aggregates per-tool line counts; a tool provider knows them, so a widget or a kernel-side rollup is the path | + +Session length arrives pre-computed rather than as a start time, because the model never reads the clock — whatever drives the shell decides how often it ticks. + +## Keymap layers — the second gap## Keymap layers — the second gap## Keymap layers — the second gap + +Bindings resolve in three layers, highest first: **overlay → focused region → global**. A layer that handles a key stops propagation. `hotkey_hints` renders the currently active layer's bindings, which is what makes the region meaningful rather than a static legend. + +| Layer | Binding | Action | +|---|---|---| +| global | `ctrl+c` | First press sends `interrupt`; second within 2s quits. Interrupting cascades to the whole sub-agent tree. | +| global | `ctrl+d` | Quit (only on an empty `input_bar`). | +| global | `tab` | Cycle focus. | +| global | `shift+tab` | Cycle the active agent. | +| global | `ctrl+b` | Toggle `sidebar` when narrow. | +| overlay | `y` / `n` | Allow / deny the focused plan item. | +| overlay | `e` | Edit arguments — opens the `corrected_input` editor. | +| overlay | `a` | Allow with `SESSION` scope. | +| overlay | `esc` | Dismiss where dismissal is meaningful; never silently resolves a pending decision. | +| main_chat | `↑`/`↓`, `pgup`/`pgdn`, `home`/`end` | Scroll; `end` re-pins to the live tail. | +| main_chat | `enter` | Activate the action under the cursor. | +| input_bar | `enter` | Submit as `user_message`. | +| input_bar | `shift+enter` | Insert a newline instead of submitting; the composer grows with it, up to six rows. | +| input_bar | `alt+enter`, `ctrl+j` | The same, for terminals that cannot disambiguate shift+enter. | +| input_bar | `↑`/`↓` | Prompt history, when the cursor is on the first/last line. | + +Plan decisions default to `PLAN_DECISION_SCOPE_ONCE`, which the protocol names as the scope a frontend SHOULD send absent explicit operator intent. `SESSION` and `ALWAYS` require the distinct keystrokes above — they are never inferred. + +There is no protocol-level keybinding registration, so a widget cannot claim a key. Widgets expose affordances as `ActionNode`s and reach the keyboard through the action cursor. This is a deliberate limitation: it keeps the keymap total and conflict-free, at the cost of widgets not being able to bind accelerators. + +## Painting a RenderTree + +The painter is a pure function from `*renderv1.RenderNode` plus a width to a styled string. It holds no terminal state, which is what lets the whole node vocabulary be tested headlessly on every CI platform including Windows. + +| Node | Treatment | +|---|---| +| `TextNode` | Styled per `TextStyle`; unset means the theme's default, distinct from an explicit `normal`. | +| `CodeBlockNode` | Bordered block, language label when set. No syntax highlighting in the skeleton. | +| `DiffNode` | Hunk headers dim, `+` green, `-` red, context plain. | +| `TableNode` | Column-aligned; flat string cells only, as the protocol defines it. | +| `LinkNode` | Label plus a dimmed URL, so the target stays visible in any terminal. | +| `ListNode` | Bulleted or numbered; recurses. | +| `GroupNode` | Transparent — no border, no indent, no label. Adding chrome here would violate the node's stated meaning. | +| `CollapsibleNode` | Summary line with a disclosure marker, honoring `collapsed_by_default`; expandable via the action cursor. | +| `SubSessionNode` | A one-line pointer to the child session with its summary — never inlined. | +| `ActionNode` | A button-styled affordance, highlighted when it is under the cursor. | + +**Unknown node types are the interesting case.** The protocol requires a frontend to render gracefully any variant added after it shipped, and `pkg/frontend`'s existing `FallbackText` already implements exactly that traversal. The painter delegates to it rather than reimplementing the fallback, and a `render_failed` on one node degrades that subtree to fallback text — it never crashes the process, which the error taxonomy states as a MUST. + +## Theme + +A small token set, not a general theming engine: one `Theme` struct mapping each `TextStyle` plus the shell's own chrome roles (border, focused border, cursor, backdrop, region title) to a `lipgloss.Style`. Two built-ins, dark and light, selected from the terminal's detected background with an explicit config override. Lip Gloss v2 degrades color automatically down to 16-color and monochrome terminals, so the tokens are authored once in truecolor. + +Keeping this a token table rather than per-call styling is what allows a later config-driven theme without touching the painter. + +## Wiring the kernel stream to Bubble Tea + +Two event loops meet here, and the bridge between them is the whole integration: + +- **Inbound**: the `Attach` stream's `ServerEvent`s are read on their own goroutine, translated one-to-one into `tea.Msg` values, and delivered with `Program.Send`. No kernel type reaches the Bubble Tea model unconverted. +- **Outbound**: operator actions become `tea.Cmd`s that write `ClientEvent`s to a buffered channel; a single writer goroutine drains it into the stream, preserving arrival order — which matters because the kernel processes `ClientEvent`s in arrival order per session and resolves decisions first-response-wins. + +The shell must handle a decision it did not win: a second response to an already-resolved item is rejected with an `invalid_client_event`-category error specifically so the UI can show "already decided elsewhere" instead of appearing to hang. The overlay renders that outcome rather than swallowing it. + +Because `Attach` is connection-scoped and multiplexes sessions by `session_id`, the shell keeps one store per attached session and paints the focused one, with `session_tree_update` driving a sub-session indicator in `top_bar`. + +## Package layout + +``` +cmd/tui/ thin entrypoint: flag parsing, TTY open, program wiring +internal/tui/theme/ the design tokens: palette -> semantic Tokens, the + spacing scale, and the border presets +internal/tui/ui/ the utility layer: the chainable Style builder plus + Panel, Bar, and Badge built from it +internal/tui/paint/ RenderTree -> styled string (pure, headless-testable) +internal/tui/region/ the placement store, ordering, streaming buffers +internal/tui/shell/ the Bubble Tea model: layout, focus, keymap, compose, + the EventSource seam, and a scripted demo source +``` + +The dependency direction is one-way and worth keeping that way: `theme` knows nothing, `ui` consumes `theme`, `paint` consumes both, and `shell` composes all of them. A color or a spacing value introduced at a call site in `shell` — rather than added to the scale in `theme` — is the failure mode this layering exists to prevent. + +`EventSource` is declared in `shell` rather than in a package of its own, because that is where it is consumed and the shell needs exactly one method of it — the house rule is to define an interface as narrowly as its consumer needs, at the consumer. When the real gRPC bridge arrives it becomes a second implementation beside the demo one; if a third ever appears, that is the point to promote the seam to the interface/driver layout, not before. + +Each package carries the `README.md` + `CLAUDE.md` pair the layout rules require. All four are pure — no I/O, no terminal, no logging — which puts them under the pure-domain exemption for instrumentation and is what lets the whole shell be tested by calling `Update` directly, with no TTY and no kernel. Logging lives in `cmd/tui`, which is where the process boundary actually is. + +### A note on shift+enter + +A bare terminal cannot distinguish `shift+enter` from `enter`: both are carriage return. Bubble Tea negotiates key disambiguation at startup (the Kitty keyboard protocol, plus `modifyOtherKeys` level 2), which makes the distinction available on terminals that support it — and most modern ones do. + +Because that negotiation can fail, `alt+enter` and `ctrl+j` are bound to the same action. `ctrl+j` is literally line feed and works everywhere, so there is always a way to insert a newline no matter what the terminal supports. + +## Deliberately deferred + +- **Syntax highlighting** in `CodeBlockNode` — a real dependency decision, not skeleton work. +- **Telling the kernel which agent is active.** See the note under "Agents": the protocol has no client event for it. The shell tracks and displays the selection; conveying it needs a protocol answer first. +- **The kernel-side attach path.** No `internal/` code launches a frontend plugin and drives its `Attach` stream yet; `cmd/agent` is non-interactive and `internal/interactive/drivers` still flags its frontend-backed driver as pending. Until that lands, `drivers/fake` is what makes the shell runnable, and it is a test fixture rather than a shipping path. +- **Widget hosting.** The shell renders widget-contributed `RenderTree`s like any other producer's, so nothing widget-specific is needed here — but no widget plugin exists to contribute yet. diff --git a/docs/first-party/index.md b/docs/first-party/index.md index 61ef16f..cb4bb92 100644 --- a/docs/first-party/index.md +++ b/docs/first-party/index.md @@ -5,10 +5,11 @@ hide: # First-party catalog -Reference material for what PluggableHarness Agent ships or studies first-party: capability reports on the four first-party model vendors and twenty-two tool capabilities common to coding agents. +Reference material for what PluggableHarness Agent ships or studies first-party: capability reports on the four first-party model vendors and twenty-two tool capabilities common to coding agents, plus design documents for the frontends it ships. > [!IMPORTANT] > These documents are descriptive, not normative. The protocol contracts — what a plugin MUST implement — live in the [specifications](../specifications/README.md); each report here cross-references the governing spec by heading anchor. - [Model providers](providers/README.md) — Anthropic, OpenAI, Google (Gemini), and xAI (Grok): model rosters, reasoning and prompt caching, wire formats and auth, with confirmed-versus-uncertain data called out honestly per vendor. - [Tools](tools/README.md) — file I/O, search, shell execution, web access, orchestration, memory, and more, each mapped to its place (or deliberate absence) in the [tool reference catalog](../specifications/tool/reference-catalog.md). +- [Frontends](frontends/README.md) — the display surfaces themselves: how a concrete frontend resolves the layout, focus, keymap, and resize questions the [frontend protocol](../specifications/frontend/README.md) deliberately leaves to the implementation. diff --git a/mkdocs.yml b/mkdocs.yml index 25f6056..958b23e 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -145,6 +145,7 @@ plugins: First-party catalog: - first-party/*.md - first-party/providers/*.md + - first-party/frontends/*.md - first-party/tools/*.md nav: @@ -223,6 +224,9 @@ nav: - OpenAI: first-party/providers/openai.md - Google: first-party/providers/google.md - xAI: first-party/providers/xai.md + - Frontends: + - first-party/frontends/README.md + - Reference TUI shell: first-party/frontends/tui.md - Tools: - first-party/tools/README.md - File read: first-party/tools/file-read.md From e59688c41b3dafaa98c666864f857b67a9f34798 Mon Sep 17 00:00:00 2001 From: Steven Crothers Date: Mon, 27 Jul 2026 10:17:43 -0400 Subject: [PATCH 08/11] ci: scope the Go build cache per job MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit actions/setup-go derives its cache key from platform, Go version, and a hash of go.sum — and nothing else. Every Go job on the same runner OS therefore competed for one key, and since actions/cache never overwrites an existing key, the first job to finish won and every other job on that OS restored whatever that job happened to have compiled. The winner is the job that does the least work. Here it was gofmt: it compiles nothing, finished in ten seconds, and was writing an empty GOCACHE that Build, Test and golangci-lint then restored before recompiling the world. The cache list shows it plainly — six of eight Linux entries were ~7.5 KB against 150 MB for the two runs where a real job won the race, while macOS and Windows sat at ~270 MB because only one job ever runs there. That asymmetry is the tell: it is a key collision, not a Go problem. Adds .github/actions/setup-go-cached, which installs Go with setup-go's own cache disabled and restores GOCACHE + GOMODCACHE under a key scoped to the calling job. Wires it into build, test, proto and lint. The gofmt job now caches nothing at all, which is both correct — it neither resolves modules nor compiles — and removes the poisoner outright. The key stays frozen on go.sum rather than rolling per run: compiled dependencies dominate a cold build and are exactly what does not change between commits, so a rolling key would re-upload hundreds of MB per run and churn the repository's 10 GB cache budget for little gain. Dependabot needs the composite action's directory listed explicitly; "/" only covers .github/workflows, and the SHAs pinned inside a composite action would otherwise go stale unnoticed. --- .github/actions/setup-go-cached/action.yml | 84 ++++++++++++++++++++++ .github/dependabot.yml | 7 +- .github/workflows/ci.yml | 29 ++++---- .github/workflows/lint.yml | 10 ++- 4 files changed, 114 insertions(+), 16 deletions(-) create mode 100644 .github/actions/setup-go-cached/action.yml diff --git a/.github/actions/setup-go-cached/action.yml b/.github/actions/setup-go-cached/action.yml new file mode 100644 index 0000000..26d84b2 --- /dev/null +++ b/.github/actions/setup-go-cached/action.yml @@ -0,0 +1,84 @@ +# ============================================================================= +# Set up Go with a job-scoped build cache. +# +# This exists because actions/setup-go's built-in `cache: true` is actively +# harmful in a repo with more than one Go job. +# +# setup-go derives its cache key from the platform, the Go version, and a hash +# of go.sum — and nothing else. Every Go job on the same runner OS therefore +# competes for one key. actions/cache never overwrites an existing key, so the +# first job to reach its post-run step wins and every other job on that OS +# silently inherits whatever that one job happened to have compiled. +# +# The job that wins is the one that finishes first, which is the one that did +# the least work. In this repo that was `gofmt` — it runs `gofmt -l -s .`, +# compiles nothing, and finished in ten seconds. It was writing an empty +# GOCACHE under the shared key, and Build, Test and golangci-lint were all +# restoring that empty cache and recompiling the world every run. Measured on +# the cache list: six of eight Linux entries were ~7.5 KB, against 150 MB for +# the two runs where a real job happened to win the race. macOS and Windows +# were unaffected at ~270 MB, because only one job ever runs there — which is +# exactly the tell that this is a collision and not a Go problem. +# +# The fix is to scope the key per job. Each job then caches and restores its +# own compiled output, and a job that compiles nothing can no longer speak for +# one that does. +# +# Keying on go.sum alone (no run id / sha) is deliberate. The key is frozen +# until the dependency graph changes, so the cache is written once per go.sum +# and reused unchanged after that. Compiled dependencies — grpc, protobuf and +# the race-instrumented standard library above all — dominate a cold build, +# and they are precisely what does not change between commits. A rolling key +# would also cache this repo's own packages, but at the cost of re-uploading +# a few hundred MB on every run and churning through the repository's 10 GB +# cache budget in a handful of runs. +# ============================================================================= + +name: Set up Go with a job-scoped build cache +description: > + Installs the Go toolchain named in go.mod and restores GOCACHE and + GOMODCACHE under a cache key scoped to the calling job, so that jobs which + compile nothing cannot evict the caches of jobs that do. + +inputs: + job: + description: > + Cache scope for the calling job — any short stable name unique within + this repository (e.g. "build", "test", "lint"). Two jobs sharing a value + share a cache, which is only correct when they compile the same thing. + required: true + +runs: + using: composite + steps: + # cache: false — this action's whole purpose is to replace setup-go's + # cache with one that is scoped correctly. Leaving it on would restore the + # shared key over the top of ours and re-create the collision. + - name: Install Go + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: go.mod + cache: false + + # Ask the toolchain where its caches are rather than hardcoding paths: + # GOCACHE and GOMODCACHE differ across Linux, macOS and Windows, and this + # action runs on all three. + - name: Locate the Go caches + id: paths + shell: bash + run: | + echo "build=$(go env GOCACHE)" >> "$GITHUB_OUTPUT" + echo "mod=$(go env GOMODCACHE)" >> "$GITHUB_OUTPUT" + + - name: Restore Go build and module caches + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + ${{ steps.paths.outputs.build }} + ${{ steps.paths.outputs.mod }} + key: go-${{ runner.os }}-${{ inputs.job }}-${{ hashFiles('go.sum') }} + # A dependency bump changes the key and would otherwise start from + # nothing. The prefix match reuses the previous graph's compiled + # output, so only what actually changed is rebuilt. + restore-keys: | + go-${{ runner.os }}-${{ inputs.job }}- diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 64b4461..7a4a72d 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -35,8 +35,13 @@ updates: commit-message: prefix: "deps" + # "/" covers .github/workflows. Composite actions are not picked up by it — + # each one's directory has to be listed, or the SHAs pinned inside it go + # stale silently while the workflows around it stay current. - package-ecosystem: "github-actions" - directory: "/" + directories: + - "/" + - "/.github/actions/setup-go-cached" schedule: interval: "weekly" cooldown: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index affed8e..c89ddf4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -61,13 +61,10 @@ jobs: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - # cache: true keys GOMODCACHE + GOCACHE on go.sum — shared by every - # job in this workflow that sets up Go the same way. - name: Set up Go - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + uses: ./.github/actions/setup-go-cached with: - go-version-file: go.mod - cache: true + job: build - name: Download modules run: go mod download @@ -107,11 +104,12 @@ jobs: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + # Scoped per OS by runner.os inside the action, so the three legs of + # this matrix keep three separate caches rather than fighting over one. - name: Set up Go - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + uses: ./.github/actions/setup-go-cached with: - go-version-file: go.mod - cache: true + job: test - name: Test with race detector + coverage run: go test -race -shuffle=on -covermode=atomic -coverprofile=coverage.out ./... @@ -156,11 +154,17 @@ jobs: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + # No cache at all, deliberately: gofmt parses source and neither + # resolves modules nor compiles anything, so there is nothing to cache + # and nothing to restore. This job used to carry setup-go's default + # cache, and because it finishes faster than any other Go job it was the + # one that won the shared cache key and wrote an empty GOCACHE for + # everything else to restore. See .github/actions/setup-go-cached. - name: Set up Go uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: go-version-file: go.mod - cache: true + cache: false - name: Check gofmt -s run: | @@ -198,11 +202,12 @@ jobs: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + # Scoped separately from `build`: this job compiles the protoc plugins + # named by go.mod's tool directives, which no other job builds. - name: Set up Go - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + uses: ./.github/actions/setup-go-cached with: - go-version-file: go.mod - cache: true + job: proto # setup_only: we drive buf explicitly below rather than using the # action's opinionated defaults. Version pinned for reproducibility; diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 060aa91..45996e6 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -35,11 +35,15 @@ jobs: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + # Scoped separately from ci.yml's jobs: golangci-lint's type-checking + # pass populates GOCACHE with export data the plain build does not + # produce, and it is the job that benefits most from getting its own + # cache back rather than another job's. The action keeps its own small + # analysis cache alongside this one; the two are unrelated. - name: Set up Go - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + uses: ./.github/actions/setup-go-cached with: - go-version-file: go.mod - cache: true + job: lint # Full-repo enforcement, not only-new-issues: the baseline is clean, # and diff-scoped linting can miss issues surfaced by base-branch drift. From 3637b3f035796bb7fe336eef408cb3827a051b7d Mon Sep 17 00:00:00 2001 From: Steven Crothers Date: Mon, 27 Jul 2026 10:17:43 -0400 Subject: [PATCH 09/11] ci: run gosec as a binary so it can use the cache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit securego/gosec is a Docker action, and a container cannot see the runner's GOCACHE or GOMODCACHE. It was re-downloading the whole module graph and recompiling it on every run: 2m27s, the slowest job outside the test matrix, almost entirely work the cache already holds. gosec type-checks the program through go/packages, so it wants exactly the compiled export data every other Go job here already has. Installing the pinned binary and running it on the runner lets it share that cache. The trade is supply-chain shape rather than trust: a SHA-pinned Docker digest becomes a version-pinned module install verified through the Go checksum database. The version matches what the action ran, and it is noted as a manual bump since Dependabot does not track it. The job name is unchanged — branch protection matches required checks by name, so renaming it would leave merges waiting on a check that no longer reports. --- .github/workflows/security.yml | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 0e1855d..0f47b7a 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -55,12 +55,28 @@ jobs: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + # gosec runs as an installed binary rather than via securego/gosec, + # which is a Docker action. A container cannot see the runner's GOCACHE + # or GOMODCACHE, so that action re-downloaded the whole module graph and + # recompiled it on every run — 2m27s, the slowest job outside the test + # matrix, essentially all of it work the cache already holds. gosec type- + # checks the program through go/packages, so it benefits from the same + # compiled export data every other Go job here does. + - name: Set up Go + uses: ./.github/actions/setup-go-cached + with: + job: gosec + + # Version-pinned to match what the action ran. Installed into bin/ per + # the repo's build-output rule; Dependabot does not track this pin, so + # bump it deliberately. + - name: Install gosec + run: GOBIN="$GITHUB_WORKSPACE/bin" go install github.com/securego/gosec/v2/cmd/gosec@v2.28.0 + # -exclude-generated: pkg/*/proto/v1 is 100% buf-generated output; # findings there are upstream-generator noise, not actionable here. - name: Run gosec - uses: securego/gosec@9e75c0576c9878035d4221392108d458abe10fc3 # v2.28.0 - with: - args: -no-fail -exclude-generated -fmt sarif -out results.sarif ./... + run: bin/gosec -no-fail -exclude-generated -fmt sarif -out results.sarif ./... - name: Upload SARIF to GitHub code scanning uses: github/codeql-action/upload-sarif@e0647621c2984b5ed2f768cb892365bf2a616ad1 # v4.37.2 From 6f7b7f99ace1d04cc503a814ac006ca125226c8a Mon Sep 17 00:00:00 2001 From: Steven Crothers Date: Mon, 27 Jul 2026 10:36:34 -0400 Subject: [PATCH 10/11] ci: drop the cache from the protobuf job MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It only compiles the protoc plugins from go.mod's tool directives — about eight seconds of work — and restoring the 26 MB that produces costs more than it saves. Measured across the two runs: 15s uncached against 20s cached. A job has to be big enough to profit from a cache. This one is not, and carrying one here is complexity that buys nothing. --- .github/workflows/ci.yml | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c89ddf4..f28edd8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -202,12 +202,16 @@ jobs: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - # Scoped separately from `build`: this job compiles the protoc plugins - # named by go.mod's tool directives, which no other job builds. + # No cache, measured rather than assumed: this job only compiles the + # protoc plugins named by go.mod's tool directives, which is about eight + # seconds of work. Restoring the 26 MB that produces costs more than it + # saves — the job ran 15s uncached against 20s cached. A job has to be + # big enough to profit from a cache, and this one is not. - name: Set up Go - uses: ./.github/actions/setup-go-cached + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: - job: proto + go-version-file: go.mod + cache: false # setup_only: we drive buf explicitly below rather than using the # action's opinionated defaults. Version pinned for reproducibility; From c5c5fd60a50a3c1f5a20c3c05cf63880b74a05ca Mon Sep 17 00:00:00 2001 From: Steven Crothers Date: Mon, 27 Jul 2026 10:41:18 -0400 Subject: [PATCH 11/11] tui: fix the tone-uniqueness key and stale ui docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two findings from review. The tone-uniqueness test packed color channels as r<<16|g<<8|b, but RGBA returns 16-bit channels, so green overlapped red's bits and blue overlapped green's. Two distinct tones could collide on one key — reporting a duplicate that does not exist, or missing one that does. Keyed on the channels themselves now, which cannot collide by construction and throws away no precision. The ui package docs still described Bar, which was removed when StatusLine superseded it, and listed Panel twice in the symbol table. A README naming a symbol the package does not export is worse than one that omits it: it sends a reader looking for something that was deliberately deleted. Removed across README, CLAUDE.md, doc.go and the design doc, and folded the duplicate Panel rows into one that mentions the caption. --- docs/first-party/frontends/tui.md | 4 ++-- internal/tui/theme/theme_test.go | 9 +++++++-- internal/tui/ui/CLAUDE.md | 2 +- internal/tui/ui/README.md | 8 +++----- internal/tui/ui/doc.go | 2 +- 5 files changed, 14 insertions(+), 11 deletions(-) diff --git a/docs/first-party/frontends/tui.md b/docs/first-party/frontends/tui.md index 5f6c3e1..86b425f 100644 --- a/docs/first-party/frontends/tui.md +++ b/docs/first-party/frontends/tui.md @@ -88,7 +88,7 @@ ESC[m Filling broad regions is therefore unreliable in a way no amount of care at the call site fixes, and the visible result is a patchwork of bands at differing widths across every pane. `BackgroundPanel` and `BackgroundElement` remain in the token set, but they are for genuinely filled, self-contained controls — a status badge, a focused button — where the run opens and closes its own fill and cannot bleed. A test asserts that no content style sets a background. -**Utilities and components.** `internal/tui/ui` is the terminal analogue of utility classes: `Style` is a chainable builder where each method sets exactly one property, so a pane's appearance reads as a sentence where it is used — `ui.New().Bg(t.C.BackgroundPanel).Fg(t.C.TextMuted).Px(theme.Space1)`. `Panel`, `Bar`, and `Badge` are compositions of those utilities, not escapes from them. +**Utilities and components.** `internal/tui/ui` is the terminal analogue of utility classes: `Style` is a chainable builder where each method sets exactly one property, so a pane's appearance reads as a sentence where it is used — `ui.New().Bg(t.C.BackgroundPanel).Fg(t.C.TextMuted).Px(theme.Space1)`. `Panel`, `StatusLine`, and `Badge` are compositions of those utilities, not escapes from them. Two cell-accuracy rules fall out of this and are load-bearing rather than cosmetic. Every component covers **every cell it claims**, because an uncovered cell shows the terminal's own background and breaks the illusion of a full-screen application. And **tabs are expanded to spaces on the way in**: a tab measures as zero cells but a terminal advances to the next tab stop when it draws one, so an unexpanded tab in producer content paints wider than it measures, overflows its pane, and corrupts every row to its right. @@ -372,7 +372,7 @@ cmd/tui/ thin entrypoint: flag parsing, TTY open, program wiring internal/tui/theme/ the design tokens: palette -> semantic Tokens, the spacing scale, and the border presets internal/tui/ui/ the utility layer: the chainable Style builder plus - Panel, Bar, and Badge built from it + Panel, StatusLine, and Badge built from it internal/tui/paint/ RenderTree -> styled string (pure, headless-testable) internal/tui/region/ the placement store, ordering, streaming buffers internal/tui/shell/ the Bubble Tea model: layout, focus, keymap, compose, diff --git a/internal/tui/theme/theme_test.go b/internal/tui/theme/theme_test.go index 92fdcbb..954269c 100644 --- a/internal/tui/theme/theme_test.go +++ b/internal/tui/theme/theme_test.go @@ -202,7 +202,12 @@ func TestToneResolvesToDistinctColors(t *testing.T) { t.Parallel() th := theme.Dark() - seen := map[uint32]string{} + // Keyed on the channels themselves rather than a packed integer. RGBA + // returns 16-bit channels, so packing them as r<<16|g<<8|b overlaps green + // into red's bits and blue into green's — two distinct tones could collide + // on one key and this test would report a duplicate that does not exist, + // or miss one that does. + seen := map[[3]uint32]string{} tones := map[theme.Tone]string{ theme.TonePrimary: "primary", @@ -215,7 +220,7 @@ func TestToneResolvesToDistinctColors(t *testing.T) { for tone, name := range tones { r, g, b, _ := th.Tone(tone).RGBA() - key := r<<16 | g<<8 | b + key := [3]uint32{r, g, b} if other, dup := seen[key]; dup { t.Errorf("tones %s and %s resolve to the same color", name, other) diff --git a/internal/tui/ui/CLAUDE.md b/internal/tui/ui/CLAUDE.md index 7cedff7..1181c08 100644 --- a/internal/tui/ui/CLAUDE.md +++ b/internal/tui/ui/CLAUDE.md @@ -6,7 +6,7 @@ The point of this package is that padding comes from `theme.Space*` and colors f ## Components must return exact dimensions -`Panel.Render` returns exactly `Height` lines of exactly `Width` cells, and `Bar.Render` exactly one line of `Width` cells. Callers stack them without measuring, so a component that returns a ragged block silently shifts everything below it. Tests assert this across a range of sizes including degenerate ones. +`Panel.Render` returns exactly `Height` lines of exactly `Width` cells, and `StatusLine.Render` exactly one line of `Width` cells. Callers stack them without measuring, so a component that returns a ragged block silently shifts everything below it. Tests assert this across a range of sizes including degenerate ones. The subtle case: `strings.Split("", "\n")` returns one empty element, not zero. A panel with no interior (`Height == 2`) must skip its body loop outright rather than trusting an empty `FitBlock` to produce no rows. diff --git a/internal/tui/ui/README.md b/internal/tui/ui/README.md index ef188a3..eac82e6 100644 --- a/internal/tui/ui/README.md +++ b/internal/tui/ui/README.md @@ -8,18 +8,16 @@ Without a layer like this, every pane picks its own padding, its own border colo - **Values come from a scale, never a literal.** Padding is `theme.Space1`..`Space4`; colors are `theme.Tokens` fields. A pane that wants more padding picks the next step; it does not invent `3`. - **Utilities compose.** `Style` is a chainable builder where each method sets exactly one property, so a pane's appearance reads as a sentence where it is used rather than hiding in a named style somewhere else. -- **Components are compositions of utilities, not escapes from them.** `Panel` and `Bar` are built from the same builder any caller uses. +- **Components are compositions of utilities, not escapes from them.** `Panel` and `StatusLine` are built from the same builder any caller uses. ## What lives here | Symbol | Role | |---|---| | `Style` | The chainable utility builder: `Fg`, `Bg`, `P`/`Px`/`Py`, `W`/`H`/`MaxW`, `Bold`, `Italic`, `Underline`, `Align` | -| `Panel` | A titled, bordered surface. Returns exactly `Height` lines of exactly `Width` cells | -| `Bar` | A full-width single-line strip with left and right ends pushed to the edges | +| `Panel` | A titled, bordered surface — content panes, header and footer alike. Returns exactly `Height` lines of exactly `Width` cells, with an optional `Caption` in the bottom border | | `Badge` | A small filled label for status pills | | `StatusLine` | A full-width row of labelled segments, one of which absorbs the slack | -| `Panel` | A titled, bordered box — used for content panes and for the header and footer alike | | `Meter` | An inline fill bar, heavy against light stroke | | `GradientMeter` | A fill bar whose color runs across a ramp along its length | | `Fields` | A label/value list with values aligned into a column | @@ -30,7 +28,7 @@ Without a layer like this, every pane picks its own padding, its own border colo ## Two cell-accuracy rules worth knowing -**Every component covers every cell it claims.** An uncovered cell shows the terminal's own background and breaks the illusion of a full-screen application, so `Panel` and `Bar` pad out to their full size rather than returning ragged lines. +**Every component covers every cell it claims.** An uncovered cell shows the terminal's own background and breaks the illusion of a full-screen application, so `Panel` and `StatusLine` pad out to their full size rather than returning ragged lines. **Tabs are expanded on the way in.** A tab measures as zero cells but a terminal advances to the next tab stop when it draws one, so unexpanded tabs paint wider than they measure — overflowing the pane and corrupting every row to the right. Producer content routinely contains tabs (Go source, diffs). diff --git a/internal/tui/ui/doc.go b/internal/tui/ui/doc.go index efed3f0..021b0bc 100644 --- a/internal/tui/ui/doc.go +++ b/internal/tui/ui/doc.go @@ -14,7 +14,7 @@ // exactly one property, so a pane's appearance reads as a sentence at the // point of use rather than hiding in a named style elsewhere. // - Components are compositions of utilities, not escapes from them. -// Panel and Bar are built from the same Style builder any caller uses. +// Panel and StatusLine are built from the same Style builder any caller uses. // // Everything here is pure: it takes tokens and strings and returns strings. // No terminal, no I/O, no global state.