diff --git a/README.md b/README.md index bb283b0..5a22468 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,7 @@ where a record's file path is its URI: | `ll []` | list what is already on disk under a prefix | | `graph [--depth N] [--format dot\|json]` | walk links and print the subgraph | | `serve [--addr :7777]` | a dereference server: HTTP GET on a URI returns its record | +| `tui []` | a full-screen terminal browser over the namespace, keyboard-driven | | `mcp` | the same namespace as an MCP tool set for agents | ## Walkthrough diff --git a/cli/root.go b/cli/root.go index 1c56355..c33dab7 100644 --- a/cli/root.go +++ b/cli/root.go @@ -67,6 +67,7 @@ lives on. newLLCmd(), newGraphCmd(), newServeCmd(), + newTUICmd(), newMCPCmd(), newDomainsCmd(), ) diff --git a/cli/tui.go b/cli/tui.go new file mode 100644 index 0000000..70493a0 --- /dev/null +++ b/cli/tui.go @@ -0,0 +1,41 @@ +package cli + +import ( + "github.com/spf13/cobra" + + "github.com/tamnd/ant/tui" +) + +func newTUICmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "tui [uri]", + Short: "Full-screen terminal browser over the URI namespace", + Long: `tui opens the ant terminal console: a full-screen, keyboard-driven browser +over the whole resource-URI namespace. It is the third human surface beside the +CLI and the web console, sharing their vocabulary and keymap. Every screen is a +thin render of an Engine method, so it follows links, lists members, walks the +graph, and browses the on-disk cache without leaving the terminal. + + ant tui + ant tui goodreads://book/2767052 + ant tui "https://x.com/nasa" + +Press ? for the keymap, : to jump to any record, q to quit.`, + Args: cobra.MaximumNArgs(1), + RunE: func(c *cobra.Command, args []string) error { + e, err := engineFrom() + if err != nil { + return err + } + // Warm the in-memory listing index off the render loop, so the first + // dashboard or browse count comes from memory, not a cold walk. + go e.WarmIndex() + initial := "" + if len(args) == 1 { + initial = args[0] + } + return tui.Run(c.Context(), e, tui.Build{Version: Version, Commit: Commit, Date: Date}, initial) + }, + } + return cmd +} diff --git a/docs/content/reference/cli.md b/docs/content/reference/cli.md index 3b0f5f1..40ddb62 100644 --- a/docs/content/reference/cli.md +++ b/docs/content/reference/cli.md @@ -47,6 +47,7 @@ A record's file path under `--data` is its URI: | Command | What it does | |---|---| | `serve [--addr :7777]` | dereference server: HTTP GET on a URI path returns its record; `/resolve`, `/url`, `/ls`, `/links` endpoints | +| `tui []` | full-screen terminal browser over the namespace: follow links, list members, walk the graph, browse the cache, all keyboard-driven | | `mcp` | the same namespace as an MCP tool set over stdio: get/ls/links/url/resolve/domains | | `version` | print the version and exit | diff --git a/docs/content/release-notes/v0.2.0.md b/docs/content/release-notes/v0.2.0.md index 3e68a70..172be9a 100644 --- a/docs/content/release-notes/v0.2.0.md +++ b/docs/content/release-notes/v0.2.0.md @@ -2,7 +2,7 @@ title: "v0.2.0" linkTitle: "v0.2.0" description: "The web console: browse the whole URI namespace in a browser, with no page that hangs." -weight: 10 +weight: 20 --- `v0.2.0` turns `ant serve` from a one-route JSON endpoint into a full **web diff --git a/docs/content/release-notes/v0.3.0.md b/docs/content/release-notes/v0.3.0.md new file mode 100644 index 0000000..e29c323 --- /dev/null +++ b/docs/content/release-notes/v0.3.0.md @@ -0,0 +1,87 @@ +--- +title: "v0.3.0" +linkTitle: "v0.3.0" +description: "The terminal console: browse the whole URI namespace without leaving the terminal." +weight: 10 +--- + +`v0.3.0` adds `ant tui`, a full-screen **terminal console** over the entire +`ant` URI namespace. It is the third human surface beside the CLI and the web +console, and it shares their vocabulary and their keymap. Every screen is a thin +render of the same engine the other surfaces read, so it follows links, lists +members, walks the graph, and browses the on-disk cache without ever leaving the +terminal. + +Nothing else changed: the CLI verbs, the JSON envelope, and the web console are +all exactly as they were in `v0.2.0`. + +## The terminal console + +Run `ant tui` for the dashboard, or `ant tui ` to open straight onto a +record: + +``` +ant tui +ant tui goodreads://book/2767052 +ant tui "https://x.com/nasa" +``` + +It is keyboard-driven and discoverable. Press `?` for the full keymap, `:` to +jump to any record from anywhere, and `q` to quit. The screens mirror the web +console one for one: + +- **Dashboard** lists every registered domain, the place you land. +- **Domain** is one driver's home: its aliases, hosts, site, and repo, a few + example records to open, and what is already cached under it. +- **Resource** renders a record's envelope with `data`, `body`, and `raw` modes + you cycle through, and a links pane you can step into and follow across sites. +- **Collection** lists the members of a collection URI, and **links** lists the + outbound edges of a record. +- **Search** gives every domain that supports it a query box, and each hit opens + its record. +- **Graph** walks the links to a depth you pick and shows the subgraph as an + indented tree, with a DOT view a keystroke away. +- **Browse** walks the on-disk data tree as a plain folder hierarchy, so you can + see exactly what `ant` already knows without typing a URI. + +An **omnibox** (`:`) runs whatever you type through `resolve`, so a bare id, a +handle, a live URL, or a full URI all land on a record. It also understands the +`browse`, `domain`, `search`, `ls`, `graph`, and `links` verbs, so the whole +command surface is one keystroke away. + +## Back and forward that actually go back + +The console keeps a back and forward stack of whole screens, not just URLs. +Going back restores a screen exactly as you left it: the same scroll position, +the same selected row, the same loaded data. Forward returns you to where you +were. + +## Cache-first, and nothing hangs + +The read path is the same one the web console uses, so it has the same +guarantees. A record already on disk paints instantly with no network. A miss +fills in from a background fetch with the work kept off the render loop, so the +interface never freezes while a slow site answers. Viewing a record quietly +warms the records it links to, so the likely next screen is often already there. + +The console is read-only and local-first. It holds no credentials and changes +nothing on the network. The on-disk data tree is the only state, and the console +reads it freely and writes it only through the explicit export action. + +## Install and upgrade + +Every tagged version builds the same artifacts: archives for Linux, macOS, +Windows, and FreeBSD, Linux packages (deb, rpm, apk), a multi-arch container +image on GHCR, and package-manager entries. The binaries are pure Go, so there +is nothing to install alongside them. + +``` +go install github.com/tamnd/ant/cmd/ant@v0.3.0 +``` + +Or grab a binary from the [release +page](https://github.com/tamnd/ant/releases/tag/v0.3.0), or pull the image: + +``` +docker run --rm -it ghcr.io/tamnd/ant:0.3.0 tui +``` diff --git a/go.mod b/go.mod index 49b0486..3d90330 100644 --- a/go.mod +++ b/go.mod @@ -10,7 +10,29 @@ require ( github.com/tamnd/x-cli v0.2.0 ) -require github.com/yuin/goldmark v1.8.2 +require ( + charm.land/bubbles/v2 v2.1.0 + charm.land/bubbletea/v2 v2.0.7 + github.com/charmbracelet/glamour v1.0.0 + github.com/yuin/goldmark v1.8.2 +) + +require ( + github.com/alecthomas/chroma/v2 v2.20.0 // indirect + github.com/atotto/clipboard v0.1.4 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/aymerick/douceur v0.2.0 // indirect + github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 // indirect + github.com/charmbracelet/x/cellbuf v0.0.15 // indirect + github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect + github.com/dlclark/regexp2 v1.11.5 // indirect + github.com/gorilla/css v1.0.1 // indirect + github.com/microcosm-cc/bluemonday v1.0.27 // indirect + github.com/muesli/reflow v0.3.0 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/yuin/goldmark-emoji v1.0.6 // indirect + golang.org/x/term v0.44.0 // indirect +) require ( github.com/dlclark/regexp2/v2 v2.2.1 // indirect @@ -21,25 +43,24 @@ require ( ) require ( - charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251106193318-19329a3e8410 // indirect + charm.land/lipgloss/v2 v2.0.4 github.com/PuerkitoBio/goquery v1.12.0 // indirect github.com/andybalholm/cascadia v1.3.3 // indirect - github.com/charmbracelet/colorprofile v0.3.3 // indirect - github.com/charmbracelet/ultraviolet v0.0.0-20251106190538-99ea45596692 // indirect - github.com/charmbracelet/x/ansi v0.11.0 // indirect + github.com/charmbracelet/colorprofile v0.4.3 // indirect + github.com/charmbracelet/ultraviolet v0.0.0-20260525132238-948f4557a654 // indirect + github.com/charmbracelet/x/ansi v0.11.7 // indirect github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444 // 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.4.1 // indirect - github.com/clipperhouse/stringish v0.1.1 // indirect - github.com/clipperhouse/uax29/v2 v2.3.0 // 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/google/uuid v1.6.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/lucasb-eyer/go-colorful v1.3.0 // indirect + github.com/lucasb-eyer/go-colorful v1.4.0 // indirect github.com/mattn/go-isatty v0.0.22 // indirect - github.com/mattn/go-runewidth v0.0.19 // indirect + github.com/mattn/go-runewidth v0.0.23 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/mango v0.1.0 // indirect github.com/muesli/mango-cobra v1.2.0 // indirect diff --git a/go.sum b/go.sum index e8b19b0..951eb38 100644 --- a/go.sum +++ b/go.sum @@ -1,40 +1,64 @@ -charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251106193318-19329a3e8410 h1:D9PbaszZYpB4nj+d6HTWr1onlmlyuGVNfL9gAi8iB3k= -charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251106193318-19329a3e8410/go.mod h1:1qZyvvVCenJO2M1ac2mX0yyiIZJoZmDM4DG4s0udJkU= +charm.land/bubbles/v2 v2.1.0 h1:YSnNh5cPYlYjPxRrzs5VEn3vwhtEn3jVGRBT3M7/I0g= +charm.land/bubbles/v2 v2.1.0/go.mod h1:l97h4hym2hvWBVfmJDtrEHHCtkIKeTEb3TTJ4ZOB3wY= +charm.land/bubbletea/v2 v2.0.7 h1:7qw2tTAVar7m7klOPBYfTB0mniv/RuexsYwMRNxSeL0= +charm.land/bubbletea/v2 v2.0.7/go.mod h1:DGW2q8gvzHnOpMpZTORs0aySVHCox5C+2Svk0fci1qs= +charm.land/lipgloss/v2 v2.0.4 h1:lcPeVtcp23SNra7lHy8iYE4UC2aIipVQ47sbGyyxR5Q= +charm.land/lipgloss/v2 v2.0.4/go.mod h1:0653x8epbZSzdDfO/XPS1a/uYPOBeSsCssOpJOqDzik= github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE= github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/PuerkitoBio/goquery v1.12.0 h1:pAcL4g3WRXekcB9AU/y1mbKez2dbY2AajVhtkO8RIBo= github.com/PuerkitoBio/goquery v1.12.0/go.mod h1:802ej+gV2y7bbIhOIoPY5sT183ZW0YFofScC4q/hIpQ= +github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= +github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= +github.com/alecthomas/chroma/v2 v2.20.0 h1:sfIHpxPyR07/Oylvmcai3X/exDlE8+FA820NTz+9sGw= +github.com/alecthomas/chroma/v2 v2.20.0/go.mod h1:e7tViK0xh/Nf4BYHl00ycY6rV7b8iXBksI9E359yNmA= +github.com/alecthomas/repr v0.5.1 h1:E3G4t2QbHTSNpPKBgMTln5KLkZHLOcU7r37J4pXBuIg= +github.com/alecthomas/repr v0.5.1/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM= github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA= -github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= -github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= -github.com/charmbracelet/colorprofile v0.3.3 h1:DjJzJtLP6/NZ8p7Cgjno0CKGr7wwRJGxWUwh2IyhfAI= -github.com/charmbracelet/colorprofile v0.3.3/go.mod h1:nB1FugsAbzq284eJcjfah2nhdSLppN2NqvfotkfRYP4= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o= +github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w= +github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= +github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= +github.com/charmbracelet/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/fang v1.0.0 h1:jESBY40agJOlLYnnv9jE0mLqDGTxEk0hkOnx7YGyRlQ= github.com/charmbracelet/fang v1.0.0/go.mod h1:P5/DNb9DddQ0Z0dbc0P3ol4/ix5Po7Ofr2KMBfAqoCo= -github.com/charmbracelet/ultraviolet v0.0.0-20251106190538-99ea45596692 h1:r/3jQZ1LjWW6ybp8HHfhrKrwHIWiJhUuY7wwYIWZulQ= -github.com/charmbracelet/ultraviolet v0.0.0-20251106190538-99ea45596692/go.mod h1:Y8B4DzWeTb0ama8l3+KyopZtkE8fZjwRQ3aEAPEXHE0= -github.com/charmbracelet/x/ansi v0.11.0 h1:uuIVK7GIplwX6UBIz8S2TF8nkr7xRlygSsBRjSJqIvA= -github.com/charmbracelet/x/ansi v0.11.0/go.mod h1:uQt8bOrq/xgXjlGcFMc8U2WYbnxyjrKhnvTQluvfCaE= +github.com/charmbracelet/glamour v1.0.0 h1:AWMLOVFHTsysl4WV8T8QgkQ0s/ZNZo7CiE4WKhk8l08= +github.com/charmbracelet/glamour v1.0.0/go.mod h1:DSdohgOBkMr2ZQNhw4LZxSGpx3SvpeujNoXrQyH2hxo= +github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE= +github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA= +github.com/charmbracelet/ultraviolet v0.0.0-20260525132238-948f4557a654 h1:FpSYhY28ucg9ZRr+2wj67FAQ0Ey5yiK0072PmRDJNek= +github.com/charmbracelet/ultraviolet v0.0.0-20260525132238-948f4557a654/go.mod h1:hFpumms29Smx3LStRfku8vcCTBe1Kq8aCXtHUJa3mjY= +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/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= +github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444 h1:IJDiTgVE56gkAGfq0lBEloWgkXMk4hl/bmuPoicI4R0= github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444/go.mod h1:T9jr8CzFpjhFVHjNjKwbAD7KwBNyFnj2pntAO7F2zw0= 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/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI= +github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU= github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= github.com/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.4.1 h1:uVw9V8UDfnggg3K2U84VWY1YLQ/x2aKSCtkRyYozfoU= -github.com/clipperhouse/displaywidth v0.4.1/go.mod h1:R+kHuzaYWFkTm7xoMmK1lFydbci4X2CicfbGstSGg0o= -github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= -github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= -github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4= -github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= +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/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= 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= +github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= +github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dlclark/regexp2/v2 v2.2.1 h1:mf4KkFUj0gJuarK8P+LgiS+Lit7m9N1yAwEfPbee7R0= github.com/dlclark/regexp2/v2 v2.2.1/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU= github.com/dop251/goja v0.0.0-20260607120635-348e6bea910d h1:xbM5U2EvWKkHxzEQJ2DEn20FwolWZahuTnVHr6WL3Q4= @@ -50,16 +74,23 @@ github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17k github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= +github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= +github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= -github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4= +github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= -github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= -github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= +github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw= +github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= +github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/mango v0.1.0 h1:DZQK45d2gGbql1arsYA4vfg4d7I9Hfx5rX/GCmzsAvI= @@ -68,14 +99,20 @@ github.com/muesli/mango-cobra v1.2.0 h1:DQvjzAM0PMZr85Iv9LIMaYISpTOliMEg+uMFtNbY github.com/muesli/mango-cobra v1.2.0/go.mod h1:vMJL54QytZAJhCT13LPVDfkvCUJ5/4jNUKF/8NC2UjA= github.com/muesli/mango-pflag v0.1.0 h1:UADqbYgpUyRoBja3g6LUL+3LErjpsOwaC9ywvBWe7Sg= github.com/muesli/mango-pflag v0.1.0/go.mod h1:YEQomTxaCUp8PrbhFh10UfbhbQrM/xJ4i2PB8VTLLW0= +github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= +github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= github.com/muesli/roff v0.1.0 h1:YD0lalCotmYuF5HhZliKWlIx7IEhiXeSfq7hNjFqGF8= github.com/muesli/roff v0.1.0/go.mod h1:pjAHQM9hdUUwm/krAfrLGgJkXJ+YuhtsfZ42kieB2Ig= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 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.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= @@ -100,6 +137,8 @@ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJu github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE= github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= +github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs= +github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= @@ -158,6 +197,8 @@ golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= +golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= +golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= diff --git a/tui/app.go b/tui/app.go new file mode 100644 index 0000000..0c8102e --- /dev/null +++ b/tui/app.go @@ -0,0 +1,351 @@ +package tui + +import ( + "fmt" + "strings" + "time" + + "charm.land/bubbles/v2/key" + "charm.land/bubbles/v2/spinner" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + + "github.com/tamnd/any-cli/kit" +) + +// App is the root model: it owns the screen stack, the omnibox, the help overlay, +// and the chrome (title bar + status bar). Screens never touch the stack +// directly; they emit navigate/push/back messages and the App is the single +// place that mutates it, which is what makes back/forward restore a screen +// exactly as it was left (8000_ant_tui §5, §12). +type App struct { + d *deps + stack []Screen // the back-stack; stack[0] is always the dashboard, top is active + fwd []Screen // popped screens, for forward + omni omnibox + helpOpen bool + spin spinner.Model + w, h int + toast string + toastErr bool + initURI *kit.URI // optional record to open after the first size arrives +} + +// newApp seeds the stack with the dashboard. If initURI is set, the App opens it +// once sized, so back from it returns to the dashboard. +func newApp(d *deps, initURI *kit.URI) *App { + return &App{ + d: d, + spin: spinner.New(spinner.WithSpinner(spinner.Dot)), + omni: newOmnibox(d), + stack: []Screen{newDashboardScreen(d)}, + initURI: initURI, + } +} + +func (a *App) Init() tea.Cmd { + cmds := []tea.Cmd{a.spin.Tick, tea.RequestBackgroundColor, a.active().Init()} + if a.initURI != nil { + u := *a.initURI + cmds = append(cmds, func() tea.Msg { return navigateMsg{URI: u} }) + } + return tea.Batch(cmds...) +} + +func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + a.w, a.h = msg.Width, msg.Height + a.omni.setWidth(msg.Width) + return a, a.resizeAll() + + case tea.BackgroundColorMsg: + a.d.sty = newStyles(msg.IsDark()) + return a, a.resizeAll() + + case spinner.TickMsg: + var cmd tea.Cmd + a.spin, cmd = a.spin.Update(msg) + return a, cmd + + case tea.KeyPressMsg: + return a.onKey(msg) + + case navigateMsg: + return a, a.open(newResourceScreen(a.d, msg.URI, msg.Refresh)) + case pushMsg: + return a, a.open(msg.Screen) + case replaceMsg: + return a, a.replace(msg.Screen) + case backMsg: + a.back() + return a, nil + case forwardMsg: + return a, a.forward() + case homeMsg: + a.goHome() + return a, nil + + case resolvedMsg: + if msg.Err != nil { + return a, toastCmd("could not resolve: "+msg.Err.Error(), true) + } + return a, a.open(newResourceScreen(a.d, msg.URI, false)) + + case exportedMsg: + if msg.Err != nil { + return a, toastCmd("export failed: "+msg.Err.Error(), true) + } + return a, toastCmd(fmt.Sprintf("exported %d records under %s", len(msg.Report.Written), msg.Report.Root), false) + + case toastMsg: + a.toast, a.toastErr = msg.Text, msg.IsErr + return a, tea.Tick(3*time.Second, func(time.Time) tea.Msg { return clearToastMsg{} }) + case clearToastMsg: + a.toast = "" + return a, nil + + default: + // Data results (fetched/listed/walked/searched/ll/rendered) are broadcast + // to every screen so the one waiting on the key installs it, even a + // background screen warmed by prefetch (8000_ant_tui §12). + return a, a.broadcast(msg) + } +} + +func (a *App) View() tea.View { + if a.w == 0 || a.h == 0 { + v := tea.NewView("") + v.AltScreen = true + return v + } + bw, bh := a.bodySize() + var body string + if a.helpOpen { + body = renderHelp(a.d.sty, bw, bh, a.helpGroups()) + } else { + body = a.active().View(bw, bh) + } + content := strings.Join([]string{a.titleBar(), padLines(body, bw, bh), a.bottomBar()}, "\n") + v := tea.NewView(content) + v.AltScreen = true + return v +} + +// --- key handling ----------------------------------------------------------- + +func (a *App) onKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { + // The omnibox, while open, swallows every key. + if a.omni.active { + var cmd tea.Cmd + a.omni, cmd = a.omni.update(msg) + return a, cmd + } + // Help overlay: any key dismisses it. + if a.helpOpen { + a.helpOpen = false + return a, nil + } + // ctrl+c always quits, even mid-edit. + if msg.String() == "ctrl+c" { + return a, tea.Quit + } + // A screen editing text claims every key; only ctrl+c (above) escapes it. + if a.active().Capturing() { + return a, a.routeToScreen(msg) + } + + k := a.d.keys + switch { + case key.Matches(msg, k.Quit): + return a, tea.Quit + case key.Matches(msg, k.Help): + a.helpOpen = true + return a, nil + case key.Matches(msg, k.Omni): + seed := "" + if u, ok := a.active().Subject(); ok { + seed = u.String() + } + return a, a.omni.open(seed) + case key.Matches(msg, k.Theme): + a.d.sty = newStyles(!a.d.sty.dark) + return a, a.resizeAll() + case key.Matches(msg, k.Home): + a.goHome() + return a, nil + case key.Matches(msg, k.Forward): + return a, a.forward() + case key.Matches(msg, k.Back): + if len(a.stack) > 1 { + a.back() + return a, nil + } + return a, a.routeToScreen(msg) + default: + return a, a.routeToScreen(msg) + } +} + +func (a *App) routeToScreen(msg tea.Msg) tea.Cmd { + ns, cmd := a.active().Update(msg) + a.setActive(ns) + return cmd +} + +// --- stack operations ------------------------------------------------------- + +func (a *App) active() Screen { return a.stack[len(a.stack)-1] } +func (a *App) setActive(s Screen) { a.stack[len(a.stack)-1] = s } +func (a *App) bodySize() (int, int) { + bh := a.h - 2 + if bh < 1 { + bh = 1 + } + return a.w, bh +} + +// open sizes a new screen, pushes it, clears the forward stack, and runs its +// Init. A zero current size is fine: the next resizeAll will paint it. +func (a *App) open(s Screen) tea.Cmd { + bw, bh := a.bodySize() + s, _ = s.Update(tea.WindowSizeMsg{Width: bw, Height: bh}) + a.stack = append(a.stack, s) + a.fwd = nil + return s.Init() +} + +// replace swaps the top screen in place (an in-place refresh that adds no back +// step). +func (a *App) replace(s Screen) tea.Cmd { + bw, bh := a.bodySize() + s, _ = s.Update(tea.WindowSizeMsg{Width: bw, Height: bh}) + a.setActive(s) + return s.Init() +} + +func (a *App) back() { + if len(a.stack) <= 1 { + return + } + last := a.stack[len(a.stack)-1] + a.stack = a.stack[:len(a.stack)-1] + a.fwd = append(a.fwd, last) +} + +func (a *App) forward() tea.Cmd { + if len(a.fwd) == 0 { + return nil + } + s := a.fwd[len(a.fwd)-1] + a.fwd = a.fwd[:len(a.fwd)-1] + bw, bh := a.bodySize() + s, cmd := s.Update(tea.WindowSizeMsg{Width: bw, Height: bh}) + a.stack = append(a.stack, s) + return cmd +} + +func (a *App) goHome() { + a.fwd = nil + a.stack = a.stack[:1] +} + +// broadcast feeds a message to every screen in the stack and forward list, +// collecting their commands. +func (a *App) broadcast(msg tea.Msg) tea.Cmd { + var cmds []tea.Cmd + for i := range a.stack { + ns, cmd := a.stack[i].Update(msg) + a.stack[i] = ns + if cmd != nil { + cmds = append(cmds, cmd) + } + } + for i := range a.fwd { + ns, cmd := a.fwd[i].Update(msg) + a.fwd[i] = ns + if cmd != nil { + cmds = append(cmds, cmd) + } + } + return tea.Batch(cmds...) +} + +func (a *App) resizeAll() tea.Cmd { + bw, bh := a.bodySize() + return a.broadcast(tea.WindowSizeMsg{Width: bw, Height: bh}) +} + +// --- chrome ----------------------------------------------------------------- + +func (a *App) titleBar() string { + sty := a.d.sty + left := sty.Crumb.Render("ant") + sty.Crumb.Render(" › ") + sty.Title.Render(a.active().Title()) + if len(a.stack) > 1 { + left += sty.Muted.Render(fmt.Sprintf(" [%d]", len(a.stack))) + } + right := "" + if a.active().Loading() { + right = a.spin.View() + sty.Muted.Render("loading") + } else if a.active().Cached() { + right = sty.OK.Render("● cached") + } + return bar(left, right, a.w) +} + +func (a *App) bottomBar() string { + sty := a.d.sty + if a.omni.active { + return clampLine(a.omni.View(sty), a.w) + } + if a.toast != "" { + st := sty.OK + if a.toastErr { + st = sty.Err + } + return clampLine(st.Render(a.toast), a.w) + } + return clampLine(shortHelpLine(sty, a.active().ShortHelp(), a.d.keys), a.w) +} + +func (a *App) helpGroups() [][]key.Binding { + groups := a.active().FullHelp() + groups = append(groups, globalHelp(a.d.keys)) + return groups +} + +// --- chrome helpers --------------------------------------------------------- + +// bar lays left at the start and right at the end of a w-wide line, clamping the +// left side first when the two would collide. +func bar(left, right string, w int) string { + lw, rw := lipgloss.Width(left), lipgloss.Width(right) + if lw+rw+1 > w { + left = truncateANSI(left, max(0, w-rw-1)) + lw = lipgloss.Width(left) + } + gap := w - lw - rw + if gap < 1 { + gap = 1 + } + return left + strings.Repeat(" ", gap) + right +} + +// shortHelpLine renders the active screen's footer hints, always closing with the +// help key so '?' is discoverable from anywhere. +func shortHelpLine(sty *styles, bs []key.Binding, keys keyMap) string { + bs = append(append([]key.Binding(nil), bs...), keys.Help) + var parts []string + for _, b := range bs { + if !b.Enabled() { + continue + } + parts = append(parts, sty.Title.Render(b.Help().Key)+" "+sty.Muted.Render(b.Help().Desc)) + } + return strings.Join(parts, sty.Crumb.Render(" · ")) +} + +// toastCmd flashes a transient status line. +func toastCmd(text string, isErr bool) tea.Cmd { + return func() tea.Msg { return toastMsg{Text: text, IsErr: isErr} } +} diff --git a/tui/browse.go b/tui/browse.go new file mode 100644 index 0000000..dbc9133 --- /dev/null +++ b/tui/browse.go @@ -0,0 +1,318 @@ +package tui + +import ( + "sort" + "strings" + + "charm.land/bubbles/v2/key" + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" + + "github.com/tamnd/any-cli/kit" +) + +// browseScreen turns the on-disk data tree into a navigable column. The root +// lists the registered domains as folders (scoped to ant's own data, never the +// shared $HOME/data root); a scheme prefix lists the records cached under it, +// grouping any deeper segments back into folders. Descending pushes a new browse +// screen so the back-stack is the path you walked, the same level-navigation the +// web console's breadcrumb gives. It is pure filesystem work, always offline-safe +// (8000_ant_tui §7.8). +type browseScreen struct { + screenBase + prefix string // canonical, e.g. "" or "goodreads://" or "goodreads://book" + segs []string // splitPrefix(prefix) + all []pickItem + pick picker + ti textinput.Model + editing bool + counts map[string]int // root only: scheme -> cached record count + inflight int + err error +} + +func newBrowseScreen(d *deps, prefix string) *browseScreen { + segs := splitPrefix(prefix) + ti := textinput.New() + ti.Prompt = "" + ti.SetVirtualCursor(true) + ti.Placeholder = "filter" + s := &browseScreen{ + screenBase: screenBase{d: d}, + prefix: joinPrefix(segs), + segs: segs, + pick: newPicker(), + ti: ti, + counts: map[string]int{}, + } + s.pick.empty = "Nothing cached here yet." + return s +} + +func (s *browseScreen) Init() tea.Cmd { + if len(s.segs) == 0 { + s.buildRoot() + var cmds []tea.Cmd + for _, dom := range s.d.e.Domains() { + s.inflight++ + cmds = append(cmds, llCmd(s.d.e, dom.Scheme+"://")) + } + return tea.Batch(cmds...) + } + s.inflight = 1 + return llCmd(s.d.e, s.prefix) +} + +func (s *browseScreen) Update(msg tea.Msg) (Screen, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + s.w, s.h = msg.Width, msg.Height + if s.w > 4 { + s.ti.SetWidth(s.w - 4) + } + case llMsg: + return s.onLL(msg) + case tea.KeyPressMsg: + return s.onKey(msg) + } + return s, nil +} + +func (s *browseScreen) onLL(msg llMsg) (Screen, tea.Cmd) { + if len(s.segs) == 0 { + // One probe per domain at the root; match by prefix and refresh the count. + sc := strings.TrimSuffix(msg.Prefix, "://") + if msg.Err == nil { + s.counts[sc] = len(msg.URIs) + } + if s.inflight > 0 { + s.inflight-- + } + s.buildRoot() + s.applyFilter() + return s, nil + } + if msg.Key != llKey(s.prefix) { + return s, nil + } + s.inflight = 0 + if msg.Err != nil { + s.err = msg.Err + return s, nil + } + s.buildChildren(msg.URIs) + s.applyFilter() + return s, nil +} + +func (s *browseScreen) onKey(msg tea.KeyPressMsg) (Screen, tea.Cmd) { + k := s.d.keys + if s.editing { + switch { + case key.Matches(msg, k.Escape): + s.editing = false + s.ti.Blur() + return s, nil + case key.Matches(msg, k.Enter): + s.editing = false + s.ti.Blur() + return s, nil + default: + var cmd tea.Cmd + s.ti, cmd = s.ti.Update(msg) + s.applyFilter() + return s, cmd + } + } + if s.pick.handle(msg, k) { + return s, nil + } + switch { + case key.Matches(msg, k.Filter): + s.editing = true + return s, s.ti.Focus() + case key.Matches(msg, k.Enter), key.Matches(msg, k.Right): + if it, ok := s.pick.selected(); ok { + if it.hasURI { + return s, navigate(it.uri) + } + if it.payload != "" { + return s, push(newBrowseScreen(s.d, it.payload)) + } + } + case key.Matches(msg, k.Left): + // Ascend a level (a no-op at the root; the App keeps the dashboard below). + if len(s.segs) > 0 { + return s, goBack() + } + } + return s, nil +} + +// buildRoot lists the registered domains as folders, each carrying its cached +// record count once the per-domain probe lands. +func (s *browseScreen) buildRoot() { + var items []pickItem + for _, dom := range s.d.e.Domains() { + sub := "domain" + if c, ok := s.counts[dom.Scheme]; ok { + sub = itoa(c) + " cached" + } + items = append(items, pickItem{ + title: dom.Scheme, subtitle: sub, scheme: dom.Scheme, payload: dom.Scheme + "://", + }) + } + s.all = items +} + +// buildChildren groups the cached URIs under this prefix: a child with more +// segments below it is a folder, one that terminates here is a record. +func (s *browseScreen) buildChildren(uris []string) { + depth := len(s.segs) + folderCount := map[string]int{} + var folderOrder []string + var leaves []string + for _, uri := range uris { + us := uriSegs(uri) + if len(us) <= depth || !segsHasPrefix(us, s.segs) { + continue + } + child := us[depth] + if len(us) == depth+1 { + leaves = append(leaves, uri) + continue + } + if _, seen := folderCount[child]; !seen { + folderOrder = append(folderOrder, child) + } + folderCount[child]++ + } + sort.Strings(folderOrder) + sort.Strings(leaves) + + var items []pickItem + for _, name := range folderOrder { + child := joinPrefix(append(append([]string{}, s.segs...), name)) + items = append(items, pickItem{ + title: name + "/", subtitle: itoa(folderCount[name]) + " records", + scheme: s.segs[0], payload: child, + }) + } + for _, uri := range leaves { + if u, err := kit.ParseURI(uri); err == nil { + items = append(items, pickItem{ + title: u.Authority + "/" + u.ID(), subtitle: "record", + scheme: u.Scheme, uri: u, hasURI: true, + }) + } + } + s.all = items +} + +// applyFilter narrows the column to rows whose title contains the filter text. +func (s *browseScreen) applyFilter() { + q := strings.ToLower(strings.TrimSpace(s.ti.Value())) + if q == "" { + s.pick.setItems(s.all) + return + } + var out []pickItem + for _, it := range s.all { + if strings.Contains(strings.ToLower(it.title), q) { + out = append(out, it) + } + } + s.pick.setItems(out) +} + +func (s *browseScreen) View(w, h int) string { + sty := s.d.sty + if s.err != nil { + return padLines(sty.Err.Render("browse failed: ")+sty.Base.Render(s.err.Error()), w, h) + } + loc := "data" + if len(s.segs) > 0 { + loc = "data / " + strings.Join(s.segs, " / ") + } + head := sty.Muted.Render("browse ") + sty.Title.Render(loc) + rows := max(1, h-2) + if s.editing { + head += "\n" + sty.Crumb.Render("filter › ") + s.ti.View() + rows = max(1, h-3) + } + s.pick.setSize(w, rows) + return head + "\n\n" + s.pick.View(sty) +} + +func (s *browseScreen) Title() string { + if s.prefix == "" { + return "browse" + } + return "browse " + s.prefix +} +func (s *browseScreen) Capturing() bool { return s.editing } +func (s *browseScreen) Loading() bool { return s.inflight > 0 } +func (s *browseScreen) ShortHelp() []key.Binding { + k := s.d.keys + return []key.Binding{k.Enter, k.Left, k.Down, k.Filter, k.Back} +} +func (s *browseScreen) FullHelp() [][]key.Binding { + k := s.d.keys + return [][]key.Binding{ + {k.Up, k.Down, k.Top, k.Bottom}, + {k.Enter, k.Left, k.Right, k.Filter, k.Back}, + } +} + +// --- data-tree prefix helpers (ported from web/render.go) -------------------- + +// splitPrefix turns a browse prefix ("", "goodreads://", "goodreads://book") +// into its data-tree segments ([], [goodreads], [goodreads book]). +func splitPrefix(prefix string) []string { + p := strings.TrimSpace(prefix) + if p == "" { + return nil + } + p = strings.Replace(p, "://", "/", 1) + p = strings.Trim(p, "/") + if p == "" { + return nil + } + return strings.Split(p, "/") +} + +// joinPrefix renders segments back into a prefix: a lone scheme becomes +// "scheme://", deeper segments append under it. +func joinPrefix(segs []string) string { + switch len(segs) { + case 0: + return "" + case 1: + return segs[0] + "://" + default: + return segs[0] + "://" + strings.Join(segs[1:], "/") + } +} + +// uriSegs splits a record URI into [scheme, authority, id...], the same shape +// splitPrefix yields, so the two can be compared. +func uriSegs(uri string) []string { + u, err := kit.ParseURI(uri) + if err != nil { + return nil + } + return append([]string{u.Scheme, u.Authority}, u.Path...) +} + +// segsHasPrefix reports whether segs begins with pre. +func segsHasPrefix(segs, pre []string) bool { + if len(segs) < len(pre) { + return false + } + for i, p := range pre { + if segs[i] != p { + return false + } + } + return true +} diff --git a/tui/collection.go b/tui/collection.go new file mode 100644 index 0000000..f6ea585 --- /dev/null +++ b/tui/collection.go @@ -0,0 +1,83 @@ +package tui + +import ( + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" + + "github.com/tamnd/any-cli/kit" +) + +// collectionListN is how many members a collection screen asks for; the same +// default the CLI's ls uses. +const collectionListN = 40 + +// collectionScreen lists the members a URI expands to (ant ls): an author's +// books, a channel's videos, a category's pages. Each member is a followable row +// (8000_ant_tui §7.3). +type collectionScreen struct { + screenBase + u kit.URI + pick picker + inflight bool + err error +} + +func newCollectionScreen(d *deps, u kit.URI) *collectionScreen { + s := &collectionScreen{screenBase: screenBase{d: d}, u: u, pick: newPicker()} + s.pick.empty = "No members." + return s +} + +func (s *collectionScreen) Init() tea.Cmd { + s.inflight = true + return listCmd(s.d.ctx, s.d.e, s.u, collectionListN) +} + +func (s *collectionScreen) Update(msg tea.Msg) (Screen, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + s.w, s.h = msg.Width, msg.Height + case listedMsg: + if msg.Key != listKey(s.u, collectionListN) { + return s, nil + } + s.inflight = false + if msg.Err != nil { + s.err = msg.Err + return s, nil + } + s.pick.setItems(envItems(msg.Envs)) + case tea.KeyPressMsg: + if s.pick.handle(msg, s.d.keys) { + return s, nil + } + if key.Matches(msg, s.d.keys.Enter) { + if it, ok := s.pick.selected(); ok && it.hasURI { + return s, navigate(it.uri) + } + } + } + return s, nil +} + +func (s *collectionScreen) View(w, h int) string { + sty := s.d.sty + if s.err != nil { + return padLines(sty.Err.Render("ls failed: ")+sty.Base.Render(s.err.Error()), w, h) + } + head := sty.Muted.Render("members of ") + sty.Title.Render(s.u.String()) + s.pick.setSize(w, max(1, h-2)) + return head + "\n\n" + s.pick.View(sty) +} + +func (s *collectionScreen) Title() string { return "ls " + s.u.Authority + "/" + s.u.ID() } +func (s *collectionScreen) Subject() (kit.URI, bool) { return s.u, true } +func (s *collectionScreen) Loading() bool { return s.inflight } +func (s *collectionScreen) ShortHelp() []key.Binding { + k := s.d.keys + return []key.Binding{k.Enter, k.Down, k.Back} +} +func (s *collectionScreen) FullHelp() [][]key.Binding { + k := s.d.keys + return [][]key.Binding{{k.Up, k.Down, k.Top, k.Bottom, k.Enter, k.Back}} +} diff --git a/tui/components.go b/tui/components.go new file mode 100644 index 0000000..890dae4 --- /dev/null +++ b/tui/components.go @@ -0,0 +1,233 @@ +package tui + +import ( + "strings" + + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + + "github.com/tamnd/any-cli/kit" +) + +// pickItem is one row in a picker: a title, an optional dim subtitle, and the URI +// it opens (if any). The scheme drives the leading accent dot. +type pickItem struct { + title string + subtitle string + scheme string + uri kit.URI + hasURI bool + // payload carries screen-specific data a row may need on Enter (a browse + // prefix, a domain scheme) without a parallel slice. + payload string +} + +// picker is the program's one list widget: a windowed, cursor-driven column used +// by every collection-shaped screen (links, ls, search, graph, browse, domains). +// Rolling our own keeps full control of the row rendering (accent dots, dim +// subtitles) and makes the screens trivial to unit-test, since selection is plain +// state rather than a delegate (8000_ant_tui §7.3, §3.3). +type picker struct { + items []pickItem + cursor int + off int + w, h int + empty string +} + +func newPicker() picker { return picker{empty: "Nothing here."} } + +func (p *picker) setItems(items []pickItem) { + p.items = items + if p.cursor >= len(items) { + p.cursor = max(0, len(items)-1) + } + p.clamp() +} + +func (p *picker) setSize(w, h int) { + p.w, p.h = w, h + p.clamp() +} + +func (p *picker) up() { p.move(-1) } +func (p *picker) down() { p.move(1) } +func (p *picker) top() { p.cursor = 0; p.clamp() } +func (p *picker) bottom() { p.cursor = max(0, len(p.items)-1); p.clamp() } + +func (p *picker) move(d int) { + if len(p.items) == 0 { + return + } + p.cursor += d + if p.cursor < 0 { + p.cursor = 0 + } + if p.cursor >= len(p.items) { + p.cursor = len(p.items) - 1 + } + p.clamp() +} + +// clamp keeps the cursor inside the visible window, scrolling the window to +// follow it. +func (p *picker) clamp() { + if p.h <= 0 { + return + } + if p.cursor < p.off { + p.off = p.cursor + } + if p.cursor >= p.off+p.h { + p.off = p.cursor - p.h + 1 + } + if p.off < 0 { + p.off = 0 + } +} + +func (p *picker) selected() (pickItem, bool) { + if p.cursor < 0 || p.cursor >= len(p.items) { + return pickItem{}, false + } + return p.items[p.cursor], true +} + +// handle consumes the motion keys a picker owns and reports whether it did, so a +// screen can fall through to its own keys for anything else. +func (p *picker) handle(msg tea.KeyPressMsg, keys keyMap) bool { + switch { + case key.Matches(msg, keys.Up): + p.up() + case key.Matches(msg, keys.Down): + p.down() + case key.Matches(msg, keys.Top): + p.top() + case key.Matches(msg, keys.Bottom): + p.bottom() + case key.Matches(msg, keys.HalfDown): + for i := 0; i < p.h/2; i++ { + p.down() + } + case key.Matches(msg, keys.HalfUp): + for i := 0; i < p.h/2; i++ { + p.up() + } + default: + return false + } + return true +} + +// View renders exactly h lines (padding with blanks) so the screen layout below +// it stays put as the list shortens. +func (p picker) View(sty *styles) string { + if len(p.items) == 0 { + return padLines(sty.Muted.Render(p.empty), p.w, p.h) + } + var b strings.Builder + end := min(p.off+p.h, len(p.items)) + for i := p.off; i < end; i++ { + if i > p.off { + b.WriteByte('\n') + } + b.WriteString(p.line(sty, i, i == p.cursor)) + } + return padLines(b.String(), p.w, p.h) +} + +func (p picker) line(sty *styles, i int, sel bool) string { + it := p.items[i] + dot := " " + if it.scheme != "" { + dot = sty.schemeStyle(it.scheme).Render("● ") + } + title := it.title + line := dot + title + if it.subtitle != "" { + line += " " + sty.Muted.Render(it.subtitle) + } + line = clampLine(line, p.w) + if sel { + return sty.Sel.Width(p.w).Render(stripToWidth(line, p.w)) + } + return line +} + +// envItems turns a slice of envelopes (ls members, search hits) into followable +// picker rows, labeled by record with the type as the dim subtitle. +func envItems(envs []kit.Envelope) []pickItem { + items := make([]pickItem, 0, len(envs)) + for _, e := range envs { + u, err := kit.ParseURI(e.ID) + if err != nil { + items = append(items, pickItem{title: e.ID, subtitle: e.Type, scheme: schemeOf(e.Type)}) + continue + } + items = append(items, pickItem{ + title: u.Authority + "/" + u.ID(), + subtitle: e.Type, + scheme: u.Scheme, + uri: u, + hasURI: true, + }) + } + return items +} + +// --- layout helpers --------------------------------------------------------- + +// padLines pads a block to exactly h lines and clamps each to w, so panes keep a +// fixed footprint regardless of content. +func padLines(s string, w, h int) string { + lines := strings.Split(s, "\n") + if h > 0 { + for len(lines) < h { + lines = append(lines, "") + } + if len(lines) > h { + lines = lines[:h] + } + } + if w > 0 { + for i := range lines { + lines[i] = clampLine(lines[i], w) + } + } + return strings.Join(lines, "\n") +} + +// clampLine truncates a single (possibly styled) line to a printable width w, +// leaving shorter lines untouched. +func clampLine(s string, w int) string { + if w <= 0 || lipgloss.Width(s) <= w { + return s + } + return truncateANSI(s, w) +} + +// stripToWidth returns s clamped to w but without truncating styled-width math; +// used before applying a full-width background so the highlight is exactly w wide. +func stripToWidth(s string, w int) string { + if lipgloss.Width(s) <= w { + return s + } + return truncateANSI(s, w) +} + +// truncateANSI clamps a styled string to width w by measuring visible cells and +// re-truncating on the plain text when it overflows. lipgloss styles applied to +// the result keep their codes; for our single-style lines this is sufficient and +// avoids a heavyweight ANSI-aware truncator. +func truncateANSI(s string, w int) string { + if w <= 1 { + return "" + } + // Fast path: no escape codes. + if !strings.Contains(s, "\x1b") { + return truncate(s, w) + } + // Styled: fall back to lipgloss width-aware clamp via a max-width style. + return lipgloss.NewStyle().MaxWidth(w).Render(s) +} diff --git a/tui/dashboard.go b/tui/dashboard.go new file mode 100644 index 0000000..60ae2ba --- /dev/null +++ b/tui/dashboard.go @@ -0,0 +1,83 @@ +package tui + +import ( + "strings" + + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" +) + +// dashboardScreen is the landing screen and the domain index: it lists every +// registered driver as a followable row and points at the omnibox for jumping +// straight to a record (8000_ant_tui §7.1). It is always stack[0], so Home and a +// full back-stack unwind land here. +type dashboardScreen struct { + screenBase + pick picker +} + +func newDashboardScreen(d *deps) *dashboardScreen { + s := &dashboardScreen{screenBase: screenBase{d: d}, pick: newPicker()} + s.pick.empty = "No domains registered." + s.build() + return s +} + +func (s *dashboardScreen) build() { + var items []pickItem + for _, dom := range s.d.e.Domains() { + title := dom.Scheme + if len(dom.Aliases) > 0 { + title += " " + "(" + strings.Join(dom.Aliases, ", ") + ")" + } + items = append(items, pickItem{ + title: title, + subtitle: dom.Short, + scheme: dom.Scheme, + payload: dom.Scheme, + }) + } + s.pick.setItems(items) +} + +func (s *dashboardScreen) Init() tea.Cmd { return nil } + +func (s *dashboardScreen) Update(msg tea.Msg) (Screen, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + s.w, s.h = msg.Width, msg.Height + case tea.KeyPressMsg: + if s.pick.handle(msg, s.d.keys) { + return s, nil + } + switch { + case key.Matches(msg, s.d.keys.Enter): + if it, ok := s.pick.selected(); ok { + return s, push(newDomainScreen(s.d, it.payload)) + } + case key.Matches(msg, s.d.keys.Browse): + return s, push(newBrowseScreen(s.d, "")) + } + } + return s, nil +} + +func (s *dashboardScreen) View(w, h int) string { + sty := s.d.sty + header := sty.Title.Render("Every record is a URI") + "\n" + + sty.Muted.Render("Open a domain, or press : to go to any record.") + s.pick.setSize(w, max(1, h-3)) + return header + "\n\n" + s.pick.View(sty) +} + +func (s *dashboardScreen) Title() string { return "dashboard" } + +func (s *dashboardScreen) ShortHelp() []key.Binding { + k := s.d.keys + return []key.Binding{k.Enter, k.Down, k.Browse, k.Omni} +} + +func (s *dashboardScreen) FullHelp() [][]key.Binding { + k := s.d.keys + return [][]key.Binding{{k.Up, k.Down, k.Top, k.Bottom, k.Enter, k.Browse, k.Omni}} +} diff --git a/tui/deref.go b/tui/deref.go new file mode 100644 index 0000000..a5f4621 --- /dev/null +++ b/tui/deref.go @@ -0,0 +1,118 @@ +package tui + +import ( + "context" + + tea "charm.land/bubbletea/v2" + + "github.com/tamnd/ant/ant" + "github.com/tamnd/any-cli/kit" +) + +// Deref is the slice of *ant.Engine the TUI renders, the exact seam the web +// console depends on (web.Deref). Keeping it an interface means the screens are +// driven by their Update logic alone and the whole program is testable against a +// network-free fake (8000_ant_tui §6.4, §17). +type Deref interface { + Domains() []ant.DomainInfo + Domain(scheme string) (ant.DomainInfo, bool) + Resolve(input, on string) (kit.URI, error) + URL(u kit.URI) (string, error) + Get(ctx context.Context, u kit.URI) (kit.Envelope, error) + Dereference(ctx context.Context, u kit.URI, refresh bool) (ant.Fetched, error) + Lookup(u kit.URI) (ant.Fetched, bool) + Cached(u kit.URI) bool + BodyOf(env kit.Envelope) (string, bool) + List(ctx context.Context, u kit.URI, n int) ([]kit.Envelope, error) + Searchable(scheme string) bool + Search(ctx context.Context, scheme, query string, n int) ([]kit.Envelope, error) + Links(ctx context.Context, u kit.URI) ([]kit.URI, error) + Walk(ctx context.Context, u kit.URI, depth int) (*ant.Graph, error) + Export(ctx context.Context, u kit.URI, follow int, md bool) (*ant.ExportReport, error) + LL(prefix string) ([]string, error) + Root() string +} + +// --- fetch keys ------------------------------------------------------------- +// +// A fetch key names a single in-flight read so a screen can match a result to +// the request it is waiting for, even after late prefetch results arrive on a +// background screen (8000_ant_tui §11). The shapes mirror the web console's +// fetchKey so the two surfaces reason about jobs the same way. + +func getKey(u kit.URI, refresh bool) string { + if refresh { + return "get:" + u.String() + ":fresh" + } + return "get:" + u.String() +} + +func listKey(u kit.URI, n int) string { return "ls:" + u.String() + ":" + itoa(n) } +func walkKey(u kit.URI, d int) string { return "graph:" + u.String() + ":" + itoa(d) } +func searchKey(scheme string, n int, q string) string { + return "search:" + scheme + ":" + itoa(n) + ":" + q +} +func llKey(prefix string) string { return "ll:" + prefix } +func renderKey(u kit.URI) string { return "render:" + u.String() } + +// --- command constructors --------------------------------------------------- +// +// Every constructor closes over the program context and the Deref, so the IO +// runs off the render loop and is cancelled when the program's signal context +// is (8000_ant_tui §11.4). Lookup is intentionally absent here: it is a cheap, +// network-free cache read screens call inline to paint instantly, before the +// matching background Dereference is even issued. + +func derefCmd(ctx context.Context, e Deref, u kit.URI, refresh bool) tea.Cmd { + key := getKey(u, refresh) + return func() tea.Msg { + f, err := e.Dereference(ctx, u, refresh) + return fetchedMsg{Key: key, URI: u, Refresh: refresh, Fetched: f, Err: err} + } +} + +func listCmd(ctx context.Context, e Deref, u kit.URI, n int) tea.Cmd { + key := listKey(u, n) + return func() tea.Msg { + envs, err := e.List(ctx, u, n) + return listedMsg{Key: key, URI: u, N: n, Envs: envs, Err: err} + } +} + +func walkCmd(ctx context.Context, e Deref, u kit.URI, depth int) tea.Cmd { + key := walkKey(u, depth) + return func() tea.Msg { + g, err := e.Walk(ctx, u, depth) + return walkedMsg{Key: key, URI: u, Depth: depth, Graph: g, Err: err} + } +} + +func searchCmd(ctx context.Context, e Deref, scheme, query string, n int) tea.Cmd { + key := searchKey(scheme, n, query) + return func() tea.Msg { + envs, err := e.Search(ctx, scheme, query, n) + return searchedMsg{Key: key, Scheme: scheme, Query: query, N: n, Envs: envs, Err: err} + } +} + +func llCmd(e Deref, prefix string) tea.Cmd { + key := llKey(prefix) + return func() tea.Msg { + uris, err := e.LL(prefix) + return llMsg{Key: key, Prefix: prefix, URIs: uris, Err: err} + } +} + +func resolveCmd(e Deref, input, on string) tea.Cmd { + return func() tea.Msg { + u, err := e.Resolve(input, on) + return resolvedMsg{Input: input, On: on, URI: u, Err: err} + } +} + +func exportCmd(ctx context.Context, e Deref, u kit.URI, follow int, md bool) tea.Cmd { + return func() tea.Msg { + rep, err := e.Export(ctx, u, follow, md) + return exportedMsg{URI: u, Report: rep, Err: err} + } +} diff --git a/tui/domain.go b/tui/domain.go new file mode 100644 index 0000000..ca18aec --- /dev/null +++ b/tui/domain.go @@ -0,0 +1,153 @@ +package tui + +import ( + "strings" + + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" + + "github.com/tamnd/ant/ant" + "github.com/tamnd/any-cli/kit" +) + +// exampleURIs offers a couple of try-me URIs per known scheme, mirroring the web +// console's domain page so a reader has somewhere to start. Unknown drivers get +// none, which is better than a fabricated id (8000_ant_tui §7.9). +var exampleURIs = map[string][]string{ + "goodreads": {"goodreads://book/2767052", "goodreads://author/153394"}, + "x": {"x://user/nasa", "x://status/20"}, + "wikipedia": {"wikipedia://page/Alan_Turing", "wikipedia://category/Computability_theory"}, + "youtube": {"youtube://video/dQw4w9WgXcQ", "youtube://channel/UCuAXFkgsw1L7xaCfnd5JJOw"}, +} + +// domainScreen is one driver's home: its identity (aliases, hosts, site, repo), +// a few example records to open, and the records already cached under it, with a +// jump into that domain's search. +type domainScreen struct { + scheme string + info ant.DomainInfo + hasInfo bool + searchable bool + examples []pickItem + recent []pickItem + pick picker + inflight bool + screenBase +} + +func newDomainScreen(d *deps, scheme string) *domainScreen { + s := &domainScreen{screenBase: screenBase{d: d}, scheme: scheme, pick: newPicker()} + s.info, s.hasInfo = d.e.Domain(scheme) + s.searchable = d.e.Searchable(scheme) + for _, ex := range exampleURIs[scheme] { + if u, err := kit.ParseURI(ex); err == nil { + s.examples = append(s.examples, pickItem{ + title: u.Authority + "/" + u.ID(), subtitle: "example", scheme: scheme, uri: u, hasURI: true, + }) + } + } + s.rebuild() + return s +} + +func (s *domainScreen) rebuild() { + items := append([]pickItem(nil), s.examples...) + items = append(items, s.recent...) + if len(items) == 0 { + s.pick.empty = "Nothing cached yet. Press : to open a record." + } + s.pick.setItems(items) +} + +func (s *domainScreen) Init() tea.Cmd { + s.inflight = true + return llCmd(s.d.e, s.scheme+"://") +} + +func (s *domainScreen) Update(msg tea.Msg) (Screen, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + s.w, s.h = msg.Width, msg.Height + case llMsg: + if msg.Key != llKey(s.scheme+"://") { + return s, nil + } + s.inflight = false + if msg.Err == nil { + var recent []pickItem + for _, raw := range msg.URIs { + if u, err := kit.ParseURI(raw); err == nil { + recent = append(recent, pickItem{ + title: u.Authority + "/" + u.ID(), subtitle: "cached", scheme: s.scheme, uri: u, hasURI: true, + }) + } + } + s.recent = recent + s.rebuild() + } + case tea.KeyPressMsg: + if s.pick.handle(msg, s.d.keys) { + return s, nil + } + switch { + case key.Matches(msg, s.d.keys.Enter): + if it, ok := s.pick.selected(); ok && it.hasURI { + return s, navigate(it.uri) + } + case key.Matches(msg, s.d.keys.Filter), key.Matches(msg, s.d.keys.List): + if s.searchable { + return s, push(newSearchScreen(s.d, s.scheme)) + } + case key.Matches(msg, s.d.keys.Browse): + return s, push(newBrowseScreen(s.d, s.scheme+"://")) + } + } + return s, nil +} + +func (s *domainScreen) View(w, h int) string { + sty := s.d.sty + var head strings.Builder + head.WriteString(sty.schemeStyle(s.scheme).Render("● "+s.scheme) + " ") + if s.hasInfo && len(s.info.Aliases) > 0 { + head.WriteString(sty.Muted.Render("(" + strings.Join(s.info.Aliases, ", ") + ")")) + } + head.WriteByte('\n') + if s.hasInfo { + if s.info.Short != "" { + head.WriteString(sty.Base.Render(s.info.Short) + "\n") + } + var meta []string + if s.info.Site != "" { + meta = append(meta, "site "+s.info.Site) + } + if len(s.info.Hosts) > 0 { + meta = append(meta, "hosts "+strings.Join(s.info.Hosts, ", ")) + } + if s.searchable { + meta = append(meta, "searchable (/)") + } + if len(meta) > 0 { + head.WriteString(sty.Muted.Render(strings.Join(meta, sty.Crumb.Render(" · "))) + "\n") + } + } + header := head.String() + lines := strings.Count(header, "\n") + s.pick.setSize(w, max(1, h-lines-1)) + return header + "\n" + s.pick.View(sty) +} + +func (s *domainScreen) Title() string { return "domain " + s.scheme } +func (s *domainScreen) Loading() bool { return s.inflight } +func (s *domainScreen) ShortHelp() []key.Binding { + k := s.d.keys + out := []key.Binding{k.Enter, k.Down} + if s.searchable { + out = append(out, k.Filter) + } + return append(out, k.Browse, k.Back) +} +func (s *domainScreen) FullHelp() [][]key.Binding { + k := s.d.keys + return [][]key.Binding{{k.Up, k.Down, k.Top, k.Bottom, k.Enter, k.Filter, k.Back}} +} diff --git a/tui/fake_test.go b/tui/fake_test.go new file mode 100644 index 0000000..41bc94b --- /dev/null +++ b/tui/fake_test.go @@ -0,0 +1,160 @@ +package tui + +import ( + "context" + "encoding/json" + + tea "charm.land/bubbletea/v2" + + "github.com/tamnd/ant/ant" + "github.com/tamnd/any-cli/kit" +) + +// fakeDeref is a network-free stand-in for *ant.Engine, so every screen can be +// driven and asserted in a unit test (8000_ant_tui §17). It mirrors the web +// console's fake so the two surfaces are exercised against the same shapes. +type fakeDeref struct { + cold bool // when set, Lookup misses so the cold background-deref path is taken +} + +func mustURI(s string) kit.URI { + u, err := kit.ParseURI(s) + if err != nil { + panic(err) + } + return u +} + +func (fakeDeref) env(u kit.URI) kit.Envelope { + return kit.Envelope{ + ID: u.String(), + Type: "demo/" + u.Authority, + Fetched: "2026-06-14T08:00:00Z", + Links: map[string][]string{"maker_id": {"demo://maker/m1"}}, + Data: map[string]any{ + "id": u.ID(), + "name": "Widget " + u.ID(), + "description": "a demo record", + "maker_id": "demo://maker/m1", + }, + } +} + +func (fakeDeref) Domains() []ant.DomainInfo { + return []ant.DomainInfo{{ + Scheme: "demo", Aliases: []string{"dm"}, Hosts: []string{"demo.example"}, + Binary: "demo", Short: "A demo domain", Site: "https://demo.example", + Repo: "https://example.com/demo", + }} +} + +func (f fakeDeref) Domain(s string) (ant.DomainInfo, bool) { + for _, d := range f.Domains() { + if d.Scheme == s { + return d, true + } + } + return ant.DomainInfo{}, false +} + +func (fakeDeref) Resolve(input, on string) (kit.URI, error) { return mustURI("demo://widget/42"), nil } +func (fakeDeref) URL(u kit.URI) (string, error) { return "https://demo.example/" + u.ID(), nil } + +func (f fakeDeref) Get(_ context.Context, u kit.URI) (kit.Envelope, error) { return f.env(u), nil } + +func (f fakeDeref) Dereference(_ context.Context, u kit.URI, refresh bool) (ant.Fetched, error) { + env := f.env(u) + raw, _ := json.MarshalIndent(env, "", " ") + return ant.Fetched{Env: env, Raw: raw, Body: "# Body\n\nHello from the body.", HasBody: true, FromCache: !refresh}, nil +} + +func (f fakeDeref) Lookup(u kit.URI) (ant.Fetched, bool) { + if f.cold { + return ant.Fetched{}, false + } + env := f.env(u) + raw, _ := json.MarshalIndent(env, "", " ") + return ant.Fetched{Env: env, Raw: raw, Body: "# Body\n\nHello from the body.", HasBody: true, FromCache: true}, true +} + +func (f fakeDeref) Cached(u kit.URI) bool { return !f.cold } +func (fakeDeref) BodyOf(kit.Envelope) (string, bool) { return "body", true } + +func (f fakeDeref) List(_ context.Context, u kit.URI, n int) ([]kit.Envelope, error) { + return []kit.Envelope{f.env(mustURI("demo://widget/1")), f.env(mustURI("demo://widget/2"))}, nil +} + +func (fakeDeref) Searchable(s string) bool { return s == "demo" } + +func (f fakeDeref) Search(_ context.Context, scheme, q string, n int) ([]kit.Envelope, error) { + return []kit.Envelope{f.env(mustURI("demo://widget/7"))}, nil +} + +func (fakeDeref) Links(_ context.Context, u kit.URI) ([]kit.URI, error) { + return []kit.URI{mustURI("demo://maker/m1")}, nil +} + +func (fakeDeref) Walk(_ context.Context, u kit.URI, depth int) (*ant.Graph, error) { + return &ant.Graph{ + Nodes: []ant.GraphNode{ + {URI: "demo://widget/42", Type: "demo/widget"}, + {URI: "demo://maker/m1", Type: "demo/maker"}, + }, + Edges: []ant.GraphEdge{{From: "demo://widget/42", To: "demo://maker/m1"}}, + }, nil +} + +func (fakeDeref) Export(_ context.Context, u kit.URI, follow int, md bool) (*ant.ExportReport, error) { + return &ant.ExportReport{Root: u.String(), Written: []string{u.String()}}, nil +} + +func (fakeDeref) LL(prefix string) ([]string, error) { + return []string{"demo://widget/42", "demo://widget/1", "demo://maker/m1"}, nil +} + +func (fakeDeref) Root() string { return "/tmp/data" } + +// --- test helpers ----------------------------------------------------------- + +func testDeps(e Deref) *deps { + return &deps{ + e: e, + ctx: context.Background(), + keys: newKeys(), + sty: newStyles(true), + build: Build{Version: "test", Commit: "abc1234", Date: "2026-06-14"}, + } +} + +// drain runs a command to its message, flattening a Batch into the list of every +// message its child commands produce, so a test can find the one it cares about. +func drain(cmd tea.Cmd) []tea.Msg { + if cmd == nil { + return nil + } + msg := cmd() + if b, ok := msg.(tea.BatchMsg); ok { + var out []tea.Msg + for _, c := range b { + out = append(out, drain(c)...) + } + return out + } + return []tea.Msg{msg} +} + +// key constructors for tests. +func kRune(r rune) tea.KeyPressMsg { return tea.KeyPressMsg{Code: r, Text: string(r)} } +func kCode(c rune) tea.KeyPressMsg { return tea.KeyPressMsg{Code: c} } + +var ( + kEnter = kCode(tea.KeyEnter) + kEsc = kCode(tea.KeyEscape) + kDown = kCode(tea.KeyDown) + kTab = kCode(tea.KeyTab) +) + +func sized(s Screen, w, h int) Screen { + s, _ = s.Update(tea.WindowSizeMsg{Width: w, Height: h}) + return s +} diff --git a/tui/flow_test.go b/tui/flow_test.go new file mode 100644 index 0000000..988e525 --- /dev/null +++ b/tui/flow_test.go @@ -0,0 +1,139 @@ +package tui + +import ( + "testing" + + tea "charm.land/bubbletea/v2" +) + +func TestOmniboxVerbs(t *testing.T) { + d := testDeps(fakeDeref{}) + + cases := []struct { + input string + check func(t *testing.T, s Screen) + }{ + {"browse demo://", func(t *testing.T, s Screen) { + b, ok := s.(*browseScreen) + if !ok || b.prefix != "demo://" { + t.Fatalf("browse verb should open browse demo://, got %T", s) + } + }}, + {"domain demo", func(t *testing.T, s Screen) { + if _, ok := s.(*domainScreen); !ok { + t.Fatalf("domain verb should open a domain screen, got %T", s) + } + }}, + {"search demo rockets", func(t *testing.T, s Screen) { + ss, ok := s.(*searchScreen) + if !ok || ss.pending != "rockets" { + t.Fatalf("search verb should seed the query, got %T", s) + } + }}, + {"graph demo://widget/42 2", func(t *testing.T, s Screen) { + g, ok := s.(*graphScreen) + if !ok || g.depth != 2 { + t.Fatalf("graph verb should carry the depth, got %T", s) + } + }}, + {"ls demo://maker/m1", func(t *testing.T, s Screen) { + if _, ok := s.(*collectionScreen); !ok { + t.Fatalf("ls verb should open a collection, got %T", s) + } + }}, + } + for _, tc := range cases { + o := newOmnibox(d) + o.open(tc.input) + _, cmd := o.update(kEnter) + pm, ok := findMsg[pushMsg](drain(cmd)) + if !ok { + t.Fatalf("%q should push a screen", tc.input) + } + tc.check(t, pm.Screen) + } +} + +func TestOmniboxBareInputResolves(t *testing.T) { + o := newOmnibox(testDeps(fakeDeref{})) + o.open("demo://widget/42") + _, cmd := o.update(kEnter) + if _, ok := findMsg[resolvedMsg](drain(cmd)); !ok { + t.Fatal("a bare address should run through Resolve") + } +} + +func TestAppBackForwardStack(t *testing.T) { + a := newApp(testDeps(fakeDeref{}), nil) + m, _ := a.Update(tea.WindowSizeMsg{Width: 80, Height: 24}) + a = m.(*App) + if len(a.stack) != 1 { + t.Fatalf("app should start on the dashboard alone, got %d", len(a.stack)) + } + + m, _ = a.Update(navigateMsg{URI: mustURI("demo://widget/42")}) + a = m.(*App) + if len(a.stack) != 2 { + t.Fatalf("navigate should push a resource, got depth %d", len(a.stack)) + } + if _, ok := a.active().(*resourceScreen); !ok { + t.Fatalf("top of stack should be the resource, got %T", a.active()) + } + + m, _ = a.Update(backMsg{}) + a = m.(*App) + if len(a.stack) != 1 { + t.Fatalf("back should pop to the dashboard, got %d", len(a.stack)) + } + + m, _ = a.Update(forwardMsg{}) + a = m.(*App) + if len(a.stack) != 2 { + t.Fatalf("forward should re-push, got %d", len(a.stack)) + } +} + +func TestAppGlobalKeys(t *testing.T) { + a := newApp(testDeps(fakeDeref{}), nil) + m, _ := a.Update(tea.WindowSizeMsg{Width: 80, Height: 24}) + a = m.(*App) + + // Theme toggle flips the palette. + wasDark := a.d.sty.dark + m, _ = a.Update(kRune('T')) + a = m.(*App) + if a.d.sty.dark == wasDark { + t.Fatal("T should toggle the theme") + } + + // ':' opens the omnibox. + m, _ = a.Update(kRune(':')) + a = m.(*App) + if !a.omni.active { + t.Fatal(": should open the omnibox") + } + // While the omnibox is open, Esc closes it rather than going back. + m, _ = a.Update(kEsc) + a = m.(*App) + if a.omni.active { + t.Fatal("esc should close the omnibox") + } + + // '?' opens help; any key closes it. + m, _ = a.Update(kRune('?')) + a = m.(*App) + if !a.helpOpen { + t.Fatal("? should open help") + } + + // 'q' quits. + _, cmd := a.Update(kRune('q')) + if _, ok := findMsg[tea.QuitMsg](drain(cmd)); !ok { + // help is open, so the first key closes help instead of quitting; reissue. + a.helpOpen = false + _, cmd = a.Update(kRune('q')) + if _, ok := findMsg[tea.QuitMsg](drain(cmd)); !ok { + t.Fatal("q should quit") + } + } +} diff --git a/tui/graph.go b/tui/graph.go new file mode 100644 index 0000000..f434715 --- /dev/null +++ b/tui/graph.go @@ -0,0 +1,196 @@ +package tui + +import ( + "fmt" + "strings" + + "charm.land/bubbles/v2/key" + "charm.land/bubbles/v2/viewport" + tea "charm.land/bubbletea/v2" + + "github.com/tamnd/ant/ant" + "github.com/tamnd/any-cli/kit" +) + +// graphScreen is the x-ray: it walks the subgraph reachable from a record within +// a depth and shows it as an indented tree of followable nodes, with a toggle to +// the raw DOT the CLI emits. '+'/'-' change the depth and re-walk (8000_ant_tui §9). +type graphScreen struct { + screenBase + u kit.URI + depth int + g *ant.Graph + pick picker + dot viewport.Model + dotMode bool + inflight bool + err error +} + +func newGraphScreen(d *deps, u kit.URI, depth int) *graphScreen { + if depth < 1 { + depth = 1 + } + s := &graphScreen{screenBase: screenBase{d: d}, u: u, depth: depth, pick: newPicker(), dot: viewport.New()} + s.pick.empty = "No nodes." + return s +} + +func (s *graphScreen) Init() tea.Cmd { + s.inflight = true + return walkCmd(s.d.ctx, s.d.e, s.u, s.depth) +} + +func (s *graphScreen) Update(msg tea.Msg) (Screen, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + s.w, s.h = msg.Width, msg.Height + case walkedMsg: + if msg.Key != walkKey(s.u, s.depth) { + return s, nil + } + s.inflight = false + if msg.Err != nil { + s.err = msg.Err + return s, nil + } + s.g = msg.Graph + s.buildItems() + s.dot.SetContent(s.d.sty.Base.Render(s.g.Dot())) + case tea.KeyPressMsg: + return s.onKey(msg) + } + return s, nil +} + +func (s *graphScreen) onKey(msg tea.KeyPressMsg) (Screen, tea.Cmd) { + k := s.d.keys + switch { + case key.Matches(msg, k.Mode): + s.dotMode = !s.dotMode + return s, nil + case msg.String() == "+", msg.String() == "=": + s.depth++ + s.inflight = true + return s, walkCmd(s.d.ctx, s.d.e, s.u, s.depth) + case msg.String() == "-", msg.String() == "_": + if s.depth > 1 { + s.depth-- + s.inflight = true + return s, walkCmd(s.d.ctx, s.d.e, s.u, s.depth) + } + return s, nil + case key.Matches(msg, k.Enter): + if !s.dotMode { + if it, ok := s.pick.selected(); ok && it.hasURI { + return s, navigate(it.uri) + } + } + return s, nil + } + if s.dotMode { + s.dotMotion(msg, k) + return s, nil + } + s.pick.handle(msg, k) + return s, nil +} + +func (s *graphScreen) dotMotion(msg tea.KeyPressMsg, k keyMap) { + switch { + case key.Matches(msg, k.Up): + s.dot.ScrollUp(1) + case key.Matches(msg, k.Down): + s.dot.ScrollDown(1) + case key.Matches(msg, k.Top): + s.dot.GotoTop() + case key.Matches(msg, k.Bottom): + s.dot.GotoBottom() + case key.Matches(msg, k.HalfUp): + s.dot.HalfPageUp() + case key.Matches(msg, k.HalfDown): + s.dot.HalfPageDown() + } +} + +func (s *graphScreen) buildItems() { + if s.g == nil { + return + } + depth := bfsDepth(s.u.String(), s.g) + var items []pickItem + for _, n := range s.g.Nodes { + indent := strings.Repeat(" ", depth[n.URI]) + if u, err := kit.ParseURI(n.URI); err == nil { + items = append(items, pickItem{ + title: indent + u.Authority + "/" + u.ID(), subtitle: n.Type, + scheme: u.Scheme, uri: u, hasURI: true, + }) + continue + } + items = append(items, pickItem{title: indent + n.URI, subtitle: n.Type, scheme: schemeOf(n.Type)}) + } + s.pick.setItems(items) +} + +// bfsDepth assigns each node its hop-distance from the root over the graph's +// edges, so the tree view can indent by depth. Unreached nodes stay at 0. +func bfsDepth(root string, g *ant.Graph) map[string]int { + adj := map[string][]string{} + for _, e := range g.Edges { + adj[e.From] = append(adj[e.From], e.To) + } + depth := map[string]int{root: 0} + queue := []string{root} + for len(queue) > 0 { + cur := queue[0] + queue = queue[1:] + for _, to := range adj[cur] { + if _, seen := depth[to]; !seen { + depth[to] = depth[cur] + 1 + queue = append(queue, to) + } + } + } + return depth +} + +func (s *graphScreen) View(w, h int) string { + sty := s.d.sty + if s.err != nil { + return padLines(sty.Err.Render("walk failed: ")+sty.Base.Render(s.err.Error()), w, h) + } + view := "tree" + if s.dotMode { + view = "dot" + } + counts := "" + if s.g != nil { + counts = fmt.Sprintf(" · %d nodes · %d edges", len(s.g.Nodes), len(s.g.Edges)) + } + head := sty.Muted.Render("graph of ") + sty.Title.Render(s.u.String()) + + sty.Muted.Render(fmt.Sprintf(" depth %d · %s%s", s.depth, view, counts)) + body := max(1, h-2) + if s.dotMode { + s.dot.SetWidth(w) + s.dot.SetHeight(body) + return head + "\n\n" + s.dot.View() + } + s.pick.setSize(w, body) + return head + "\n\n" + s.pick.View(sty) +} + +func (s *graphScreen) Title() string { return "graph " + s.u.Authority + "/" + s.u.ID() } +func (s *graphScreen) Subject() (kit.URI, bool) { return s.u, true } +func (s *graphScreen) Loading() bool { return s.inflight } +func (s *graphScreen) ShortHelp() []key.Binding { + k := s.d.keys + return []key.Binding{k.Enter, k.Mode, k.Down, k.Back} +} +func (s *graphScreen) FullHelp() [][]key.Binding { + k := s.d.keys + return [][]key.Binding{ + {k.Up, k.Down, k.Top, k.Bottom, k.Enter}, + {k.Mode, k.Back}, + } +} diff --git a/tui/help.go b/tui/help.go new file mode 100644 index 0000000..1f1ef30 --- /dev/null +++ b/tui/help.go @@ -0,0 +1,69 @@ +package tui + +import ( + "strings" + + "charm.land/bubbles/v2/key" + "charm.land/lipgloss/v2" +) + +// renderHelp draws the full-screen help overlay: the active screen's bindings +// grouped into columns, plus the always-available global keys (8000_ant_tui §10). +// It is a pure function of the bindings so it needs no model of its own; the App +// flips a bool to show it. +func renderHelp(sty *styles, w, h int, groups [][]key.Binding) string { + var cols []string + for _, g := range groups { + cols = append(cols, helpColumn(sty, g)) + } + body := lipgloss.JoinHorizontal(lipgloss.Top, spaced(cols, " ")...) + title := sty.Title.Render("ant tui — keys") + footer := sty.Muted.Render("press ? or esc to close") + block := lipgloss.JoinVertical(lipgloss.Left, title, "", body, "", footer) + return padLines(block, w, h) +} + +// helpColumn renders one group of bindings as aligned key/desc rows. +func helpColumn(sty *styles, bs []key.Binding) string { + keyW := 0 + for _, b := range bs { + if !b.Enabled() { + continue + } + if w := lipgloss.Width(b.Help().Key); w > keyW { + keyW = w + } + } + var rows []string + for _, b := range bs { + if !b.Enabled() { + continue + } + k := sty.Title.Width(keyW).Render(b.Help().Key) + rows = append(rows, k+" "+sty.Muted.Render(b.Help().Desc)) + } + return strings.Join(rows, "\n") +} + +// globalHelp is the set of keys every screen honors, shown in its own help column. +func globalHelp(keys keyMap) []key.Binding { + return []key.Binding{ + keys.Omni, keys.Back, keys.Forward, keys.Home, + keys.Domains, keys.Theme, keys.Help, keys.Quit, + } +} + +// spaced interleaves a separator column between rendered columns. +func spaced(cols []string, sep string) []string { + if len(cols) == 0 { + return cols + } + out := make([]string, 0, len(cols)*2-1) + for i, c := range cols { + if i > 0 { + out = append(out, sep) + } + out = append(out, c) + } + return out +} diff --git a/tui/keys.go b/tui/keys.go new file mode 100644 index 0000000..3ff9d0c --- /dev/null +++ b/tui/keys.go @@ -0,0 +1,86 @@ +package tui + +import "charm.land/bubbles/v2/key" + +// keyMap is the whole program's keymap in one value (8000_ant_tui §10). It is +// synthesized from the conventions of k9s, lazygit, gh-dash, glow and ranger: +// vim motion, ':' for the omnibox, Esc/Backspace for back, '?' for help. Screens +// expose the subset they honor through ShortHelp/FullHelp so the footer and the +// help overlay stay truthful per screen. +type keyMap struct { + // motion + Up key.Binding + Down key.Binding + Top key.Binding + Bottom key.Binding + HalfUp key.Binding + HalfDown key.Binding + Left key.Binding + Right key.Binding + + // navigation + Enter key.Binding + Back key.Binding + Forward key.Binding + Home key.Binding + + // actions on the current record + Refresh key.Binding + Links key.Binding + List key.Binding + Graph key.Binding + URL key.Binding + Copy key.Binding + Body key.Binding + Mode key.Binding + Export key.Binding + Browse key.Binding + Tab key.Binding + + // modal / global + Omni key.Binding + Filter key.Binding + Domains key.Binding + Help key.Binding + Theme key.Binding + Quit key.Binding + Escape key.Binding +} + +func newKeys() keyMap { + return keyMap{ + Up: key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/k", "up")), + Down: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓/j", "down")), + Top: key.NewBinding(key.WithKeys("g", "home"), key.WithHelp("g", "top")), + Bottom: key.NewBinding(key.WithKeys("G", "end"), key.WithHelp("G", "bottom")), + HalfUp: key.NewBinding(key.WithKeys("ctrl+u", "pgup"), key.WithHelp("ctrl+u", "half page up")), + HalfDown: key.NewBinding(key.WithKeys("ctrl+d", "pgdown"), key.WithHelp("ctrl+d", "half page down")), + Left: key.NewBinding(key.WithKeys("left", "h"), key.WithHelp("←/h", "left")), + Right: key.NewBinding(key.WithKeys("right", "l"), key.WithHelp("→/l", "right")), + + Enter: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "open")), + Back: key.NewBinding(key.WithKeys("esc", "backspace"), key.WithHelp("esc", "back")), + Forward: key.NewBinding(key.WithKeys("ctrl+o"), key.WithHelp("ctrl+o", "forward")), + Home: key.NewBinding(key.WithKeys("H"), key.WithHelp("H", "home")), + + Refresh: key.NewBinding(key.WithKeys("r", "ctrl+r"), key.WithHelp("r", "refresh")), + Links: key.NewBinding(key.WithKeys("L"), key.WithHelp("L", "links")), + List: key.NewBinding(key.WithKeys("s"), key.WithHelp("s", "list")), + Graph: key.NewBinding(key.WithKeys("x"), key.WithHelp("x", "graph")), + URL: key.NewBinding(key.WithKeys("u"), key.WithHelp("u", "live url")), + Copy: key.NewBinding(key.WithKeys("y"), key.WithHelp("y", "copy uri")), + Body: key.NewBinding(key.WithKeys("b"), key.WithHelp("b", "body")), + Mode: key.NewBinding(key.WithKeys("v"), key.WithHelp("v", "view: data/body/raw")), + Export: key.NewBinding(key.WithKeys("e"), key.WithHelp("e", "export")), + Browse: key.NewBinding(key.WithKeys("b"), key.WithHelp("b", "browse")), + Tab: key.NewBinding(key.WithKeys("tab"), key.WithHelp("tab", "focus")), + + Omni: key.NewBinding(key.WithKeys(":"), key.WithHelp(":", "go to")), + Filter: key.NewBinding(key.WithKeys("/"), key.WithHelp("/", "filter")), + Domains: key.NewBinding(key.WithKeys("d"), key.WithHelp("d", "domains")), + Help: key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "help")), + Theme: key.NewBinding(key.WithKeys("T"), key.WithHelp("T", "theme")), + Quit: key.NewBinding(key.WithKeys("ctrl+c", "q"), key.WithHelp("q", "quit")), + Escape: key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "cancel")), + } +} diff --git a/tui/links.go b/tui/links.go new file mode 100644 index 0000000..7a1d8b9 --- /dev/null +++ b/tui/links.go @@ -0,0 +1,84 @@ +package tui + +import ( + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" + + "github.com/tamnd/any-cli/kit" +) + +// linksScreen is the full-screen view of a record's typed outbound edges (ant +// links): every @link as a followable row, grouped by the field it came from. +// It reads the edges straight off the cached envelope, painting instantly, and +// fills in from a background Dereference only on a cold record (8000_ant_tui §7.4). +type linksScreen struct { + screenBase + u kit.URI + pick picker + inflight bool + err error +} + +func newLinksScreen(d *deps, u kit.URI) *linksScreen { + s := &linksScreen{screenBase: screenBase{d: d}, u: u, pick: newPicker()} + s.pick.empty = "No outbound links." + return s +} + +func (s *linksScreen) Init() tea.Cmd { + if f, ok := s.d.e.Lookup(s.u); ok { + s.pick.setItems(linkItemsFrom(f.Env.Links)) + return nil + } + s.inflight = true + return derefCmd(s.d.ctx, s.d.e, s.u, false) +} + +func (s *linksScreen) Update(msg tea.Msg) (Screen, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + s.w, s.h = msg.Width, msg.Height + case fetchedMsg: + if msg.URI.String() != s.u.String() { + return s, nil + } + s.inflight = false + if msg.Err != nil { + s.err = msg.Err + return s, nil + } + s.pick.setItems(linkItemsFrom(msg.Fetched.Env.Links)) + case tea.KeyPressMsg: + if s.pick.handle(msg, s.d.keys) { + return s, nil + } + if key.Matches(msg, s.d.keys.Enter) { + if it, ok := s.pick.selected(); ok && it.hasURI { + return s, navigate(it.uri) + } + } + } + return s, nil +} + +func (s *linksScreen) View(w, h int) string { + sty := s.d.sty + if s.err != nil { + return padLines(sty.Err.Render("links failed: ")+sty.Base.Render(s.err.Error()), w, h) + } + head := sty.Muted.Render("links from ") + sty.Title.Render(s.u.String()) + s.pick.setSize(w, max(1, h-2)) + return head + "\n\n" + s.pick.View(sty) +} + +func (s *linksScreen) Title() string { return "links " + s.u.Authority + "/" + s.u.ID() } +func (s *linksScreen) Subject() (kit.URI, bool) { return s.u, true } +func (s *linksScreen) Loading() bool { return s.inflight } +func (s *linksScreen) ShortHelp() []key.Binding { + k := s.d.keys + return []key.Binding{k.Enter, k.Down, k.Back} +} +func (s *linksScreen) FullHelp() [][]key.Binding { + k := s.d.keys + return [][]key.Binding{{k.Up, k.Down, k.Top, k.Bottom, k.Enter, k.Back}} +} diff --git a/tui/messages.go b/tui/messages.go new file mode 100644 index 0000000..9a35cea --- /dev/null +++ b/tui/messages.go @@ -0,0 +1,103 @@ +package tui + +import ( + "github.com/tamnd/ant/ant" + "github.com/tamnd/any-cli/kit" +) + +// The message vocabulary the program speaks (8000_ant_tui §5). Navigation +// requests (navigate/push/back/forward/home) flow up to the App, which owns the +// screen stack; data results (fetched/listed/walked/searched/ll/resolved/ +// rendered) are broadcast to every screen so whichever is waiting on the key +// picks it up, including a background screen warmed by prefetch (§12). + +// navigateMsg asks the App to open a Resource screen for a URI. It is the common +// "follow this link" request, emitted by the data pane, the links list, search +// results, the omnibox, and collection rows. +type navigateMsg struct { + URI kit.URI + Refresh bool +} + +// pushMsg asks the App to push an already-built screen (the typed screens a +// screen constructs itself: domain, search, graph, browse). The App sizes it and +// runs its Init, so a screen never has to know the chrome geometry. +type pushMsg struct{ Screen Screen } + +// replaceMsg swaps the top screen in place rather than growing the stack, for an +// in-place refresh that should not add a back step. +type replaceMsg struct{ Screen Screen } + +type backMsg struct{} +type forwardMsg struct{} +type homeMsg struct{} + +// fetchedMsg carries a Dereference result back to the screen that requested Key. +type fetchedMsg struct { + Key string + URI kit.URI + Refresh bool + Fetched ant.Fetched + Err error +} + +type listedMsg struct { + Key string + URI kit.URI + N int + Envs []kit.Envelope + Err error +} + +type walkedMsg struct { + Key string + URI kit.URI + Depth int + Graph *ant.Graph + Err error +} + +type searchedMsg struct { + Key string + Scheme string + Query string + N int + Envs []kit.Envelope + Err error +} + +type llMsg struct { + Key string + Prefix string + URIs []string + Err error +} + +type resolvedMsg struct { + Input string + On string + URI kit.URI + Err error +} + +// renderedMsg carries a glamour-rendered body back to the screen, so the +// expensive Markdown pass runs off the render loop and its result is cached. +type renderedMsg struct { + Key string + Markdown string +} + +type exportedMsg struct { + URI kit.URI + Report *ant.ExportReport + Err error +} + +// toastMsg flashes a transient line in the status bar (a copied URL, a recovered +// error); errs render in the error color. clearToastMsg retires it. +type toastMsg struct { + Text string + IsErr bool +} + +type clearToastMsg struct{} diff --git a/tui/omnibox.go b/tui/omnibox.go new file mode 100644 index 0000000..ee1ebe9 --- /dev/null +++ b/tui/omnibox.go @@ -0,0 +1,140 @@ +package tui + +import ( + "strconv" + "strings" + + "charm.land/bubbles/v2/key" + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" +) + +// omnibox is the ':' go-to bar (8000_ant_tui §7.5): a single text input that +// runs whatever you type through Engine.Resolve, so a bare id, a handle, a live +// URL, or a full resource URI all land on a record. It is owned by the App and +// overlays the status line while active; submitting emits a resolveCmd whose +// result the App turns into navigation. +type omnibox struct { + d *deps + ti textinput.Model + active bool +} + +func newOmnibox(d *deps) omnibox { + ti := textinput.New() + ti.Prompt = "" + ti.SetVirtualCursor(true) + ti.Placeholder = "scheme://type/id, a handle, or a live URL" + return omnibox{d: d, ti: ti} +} + +// open focuses the bar, seeding it with the current subject so refining an +// address is a small edit rather than retyping. +func (o *omnibox) open(seed string) tea.Cmd { + o.active = true + o.ti.SetValue(seed) + o.ti.CursorEnd() + return o.ti.Focus() +} + +func (o *omnibox) close() { + o.active = false + o.ti.Blur() + o.ti.Reset() +} + +func (o *omnibox) setWidth(w int) { + if w > 4 { + o.ti.SetWidth(w - 4) + } +} + +// update routes a key to the bar. Enter resolves the input and closes; Esc +// cancels; everything else edits. +func (o omnibox) update(msg tea.Msg) (omnibox, tea.Cmd) { + kp, ok := msg.(tea.KeyPressMsg) + if !ok { + var cmd tea.Cmd + o.ti, cmd = o.ti.Update(msg) + return o, cmd + } + switch { + case key.Matches(kp, o.d.keys.Escape): + o.close() + return o, nil + case key.Matches(kp, o.d.keys.Enter): + input := strings.TrimSpace(o.ti.Value()) + o.close() + if input == "" { + return o, nil + } + return o, o.submit(input) + default: + var cmd tea.Cmd + o.ti, cmd = o.ti.Update(msg) + return o, cmd + } +} + +// submit interprets the bar. A leading verb (browse/domain/search/ls/graph/ +// links) opens that screen directly; anything else is run through Resolve, so a +// bare id, a handle, a live URL, or a full URI all land on a record +// (8000_ant_tui §7.6). The verbs that act on a record resolve their target +// inline, since Resolve is a synchronous, offline lookup. +func (o omnibox) submit(input string) tea.Cmd { + verb, rest, _ := strings.Cut(input, " ") + rest = strings.TrimSpace(rest) + switch verb { + case "browse": + return push(newBrowseScreen(o.d, rest)) + case "domain": + if rest == "" { + return toastCmd("domain: need a scheme", true) + } + return push(newDomainScreen(o.d, rest)) + case "search": + scheme, q, _ := strings.Cut(rest, " ") + if scheme == "" { + return toastCmd("search: need a scheme", true) + } + return push(newSearchScreenWith(o.d, scheme, q)) + case "ls", "graph", "links": + return o.recordVerb(verb, rest) + default: + return resolveCmd(o.d.e, input, "") + } +} + +// recordVerb opens a record-scoped screen (ls/graph/links) by resolving the rest +// of the line to a URI first. graph accepts a trailing depth. +func (o omnibox) recordVerb(verb, rest string) tea.Cmd { + depth := 1 + target := rest + if verb == "graph" { + if head, tail, ok := strings.Cut(rest, " "); ok { + if d, err := strconv.Atoi(strings.TrimSpace(tail)); err == nil { + depth, target = d, head + } + } + } + if strings.TrimSpace(target) == "" { + return toastCmd(verb+": need a record", true) + } + u, err := o.d.e.Resolve(target, "") + if err != nil { + return toastCmd(verb+": "+err.Error(), true) + } + switch verb { + case "ls": + return push(newCollectionScreen(o.d, u)) + case "graph": + return push(newGraphScreen(o.d, u, depth)) + case "links": + return push(newLinksScreen(o.d, u)) + } + return nil +} + +func (o omnibox) View(sty *styles) string { + return sty.Title.Render("go to ") + sty.Crumb.Render("› ") + o.ti.View() +} diff --git a/tui/render_json.go b/tui/render_json.go new file mode 100644 index 0000000..c4f7ae3 --- /dev/null +++ b/tui/render_json.go @@ -0,0 +1,45 @@ +package tui + +import ( + "bytes" + "encoding/json" + "strings" +) + +// renderJSON pretty-prints an envelope's raw JSON for the raw view, lightly +// coloring object keys so the structure reads at a glance (8000_ant_tui §8.4). +// It never alters the bytes' meaning: a failed re-indent falls back to the raw +// text, so the raw view always shows exactly what the JSON API would return. +func renderJSON(raw []byte, sty *styles) string { + var buf bytes.Buffer + if err := json.Indent(&buf, raw, "", " "); err != nil { + return sty.Base.Render(string(raw)) + } + lines := strings.Split(buf.String(), "\n") + for i, ln := range lines { + lines[i] = colorJSONLine(ln, sty) + } + return strings.Join(lines, "\n") +} + +// colorJSONLine dims the object key at the head of a line (the text up to the +// first `":`), leaving the value in the base color. +func colorJSONLine(ln string, sty *styles) string { + trimmed := strings.TrimLeft(ln, " ") + indent := ln[:len(ln)-len(trimmed)] + if !strings.HasPrefix(trimmed, `"`) { + return sty.Base.Render(ln) + } + // Find the closing quote of the key, then the colon. + end := strings.Index(trimmed[1:], `"`) + if end < 0 { + return sty.Base.Render(ln) + } + keyEnd := end + 2 // include both quotes + rest := trimmed[keyEnd:] + if !strings.HasPrefix(strings.TrimLeft(rest, " "), ":") { + return sty.Base.Render(ln) + } + key := trimmed[:keyEnd] + return indent + sty.Key.Render(key) + sty.Base.Render(rest) +} diff --git a/tui/render_markdown.go b/tui/render_markdown.go new file mode 100644 index 0000000..0849eed --- /dev/null +++ b/tui/render_markdown.go @@ -0,0 +1,64 @@ +package tui + +import ( + "sync" + + tea "charm.land/bubbletea/v2" + + "github.com/charmbracelet/glamour" +) + +// Body prose is Markdown, rendered with glamour to match the web console's +// goldmark pass (8000_ant_tui §8.3). The render is the one genuinely expensive +// formatting step, so it runs off the render loop as a command and its result is +// memoized per (theme, width, body): re-entering a record, or scrolling back to +// it, reuses the cached frame instead of re-rendering. + +var ( + mdMu sync.Mutex + mdCache = map[string]string{} +) + +// renderMarkdown formats body for the terminal at the given width and theme, +// caching the result. Unsafe raw HTML is dropped by glamour's sanitizer, the +// same guarantee the web console gets from never setting goldmark's WithUnsafe. +func renderMarkdown(body string, dark bool, width int) string { + if width < 20 { + width = 20 + } + style := "light" + if dark { + style = "dark" + } + ck := style + ":" + itoa(width) + ":" + body + mdMu.Lock() + if out, ok := mdCache[ck]; ok { + mdMu.Unlock() + return out + } + mdMu.Unlock() + + r, err := glamour.NewTermRenderer( + glamour.WithStandardStyle(style), + glamour.WithWordWrap(width), + ) + if err != nil { + return body + } + out, err := r.Render(body) + if err != nil { + return body + } + mdMu.Lock() + mdCache[ck] = out + mdMu.Unlock() + return out +} + +// renderBodyCmd renders a body off the render loop and returns it tagged with key +// so the requesting screen can install it when it arrives. +func renderBodyCmd(key, body string, dark bool, width int) tea.Cmd { + return func() tea.Msg { + return renderedMsg{Key: key, Markdown: renderMarkdown(body, dark, width)} + } +} diff --git a/tui/render_test.go b/tui/render_test.go new file mode 100644 index 0000000..64e2854 --- /dev/null +++ b/tui/render_test.go @@ -0,0 +1,66 @@ +package tui + +import ( + "encoding/json" + "strings" + "testing" +) + +func TestRenderDataShowsFields(t *testing.T) { + raw, _ := json.Marshal(map[string]any{"data": map[string]any{"name": "Widget 42", "id": "42"}}) + out := renderData(raw, newStyles(true), 60) + if !strings.Contains(out, "Widget 42") { + t.Fatalf("renderData should show the value:\n%s", out) + } +} + +func TestRenderJSONIndents(t *testing.T) { + raw, _ := json.Marshal(map[string]any{"a": 1}) + out := renderJSON(raw, newStyles(true)) + if !strings.Contains(out, "{") || !strings.Contains(out, "\"a\"") { + t.Fatalf("renderJSON should pretty-print:\n%s", out) + } +} + +func TestFlattenLinksSortedFollowable(t *testing.T) { + rows := flattenLinks(map[string][]string{ + "sequel_id": {"demo://book/2"}, + "author_id": {"demo://author/9"}, + "not_a_uri": {"just text"}, + }) + if len(rows) != 2 { + t.Fatalf("only parseable URIs are followable, got %d", len(rows)) + } + if rows[0].Field != "author_id" { + t.Fatalf("rows should be sorted by field, got %q first", rows[0].Field) + } +} + +func TestHumanize(t *testing.T) { + if got := humanize("maker_id"); got != "Maker id" { + t.Fatalf("humanize(maker_id) = %q", got) + } +} + +func TestRenderMarkdownCached(t *testing.T) { + body := "# Title\n\nSome *text*." + a := renderMarkdown(body, true, 60) + b := renderMarkdown(body, true, 60) + if a == "" { + t.Fatal("renderMarkdown should produce output") + } + if a != b { + t.Fatal("a cached render should be byte-identical") + } +} + +func TestSchemeStyleStable(t *testing.T) { + sty := newStyles(true) + // A known scheme and an unknown one both resolve to a usable style. + if sty.schemeStyle("goodreads").Render("x") == "" { + t.Fatal("known scheme should render") + } + if sty.schemeStyle("totally-unknown").Render("x") == "" { + t.Fatal("unknown scheme should fall back, not vanish") + } +} diff --git a/tui/render_value.go b/tui/render_value.go new file mode 100644 index 0000000..47ba430 --- /dev/null +++ b/tui/render_value.go @@ -0,0 +1,323 @@ +package tui + +import ( + "bytes" + "encoding/json" + "fmt" + "sort" + "strconv" + "strings" + "time" + + "charm.land/lipgloss/v2" + + "github.com/tamnd/any-cli/kit" +) + +// This file ports the web console's order-preserving value renderer +// (web/render.go) to the terminal: the same decode tree and the same field +// shaping, but emitting lipgloss-styled text instead of HTML. Sharing the shape +// keeps the two surfaces showing a record the same way (8000_ant_tui §8). + +// kvPair preserves a record object's declared field order (a map would lose it). +type kvPair struct { + Key string + Val any +} + +type orderedObj []kvPair + +// decodeOrdered reads JSON into an order-preserving tree: objects become +// orderedObj, arrays []any, numbers json.Number, the rest their natural types. +func decodeOrdered(dec *json.Decoder) (any, error) { + tok, err := dec.Token() + if err != nil { + return nil, err + } + delim, ok := tok.(json.Delim) + if !ok { + return tok, nil + } + switch delim { + case '{': + obj := orderedObj{} + for dec.More() { + keyTok, err := dec.Token() + if err != nil { + return nil, err + } + val, err := decodeOrdered(dec) + if err != nil { + return nil, err + } + obj = append(obj, kvPair{Key: keyTok.(string), Val: val}) + } + _, err = dec.Token() // closing '}' + return obj, err + case '[': + arr := []any{} + for dec.More() { + val, err := decodeOrdered(dec) + if err != nil { + return nil, err + } + arr = append(arr, val) + } + _, err = dec.Token() // closing ']' + return arr, err + default: + return nil, fmt.Errorf("unexpected delimiter %v", delim) + } +} + +// orderedDataFromRaw pulls the "data" object out of an envelope's raw JSON, +// preserving the record's declared field order, so a cache-loaded record shows +// its fields in the same order a freshly fetched one does (8000_ant_tui §8.2). +func orderedDataFromRaw(raw []byte) orderedObj { + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.UseNumber() + v, err := decodeOrdered(dec) + if err != nil { + return nil + } + env, ok := v.(orderedObj) + if !ok { + return nil + } + for _, kv := range env { + if kv.Key == "data" { + if d, ok := kv.Val.(orderedObj); ok { + return d + } + return nil + } + } + return nil +} + +// renderData renders a record's data object as an aligned key/value block inside +// the given content width. URI-valued fields are accented and arrow-marked so a +// reader can see at a glance which fields are followable edges (8000_ant_tui §8.1). +func renderData(raw []byte, sty *styles, width int) string { + data := orderedDataFromRaw(raw) + if len(data) == 0 { + return sty.Muted.Render("(no fields)") + } + keyW := 0 + for _, kv := range data { + if w := lipgloss.Width(humanize(kv.Key)); w > keyW { + keyW = w + } + } + if keyW > 22 { + keyW = 22 + } + valW := width - keyW - 1 + if valW < 8 { + valW = 8 + } + var b strings.Builder + for i, kv := range data { + if i > 0 { + b.WriteByte('\n') + } + key := sty.Key.Width(keyW).Render(humanize(kv.Key)) + val := valueString(kv.Key, kv.Val, sty, valW, 0) + b.WriteString(key + " " + indentAfterFirst(val, keyW+1)) + } + return b.String() +} + +// valueString renders one record value to a possibly multi-line styled string, +// the terminal analogue of valueHTML. +func valueString(field string, v any, sty *styles, width, depth int) string { + if depth > 8 { + return sty.Muted.Render("…") + } + switch x := v.(type) { + case nil: + return sty.Muted.Render("—") + case string: + return stringValue(field, x, sty, width) + case bool: + return sty.Base.Render(strconv.FormatBool(x)) + case json.Number: + return sty.Base.Render(groupNumber(x.String())) + case float64: + return sty.Base.Render(groupNumber(strconv.FormatFloat(x, 'f', -1, 64))) + case orderedObj: + if len(x) == 0 { + return sty.Muted.Render("{}") + } + var lines []string + for _, kv := range x { + val := oneLine(valueString(kv.Key, kv.Val, sty, width, depth+1)) + lines = append(lines, sty.Key.Render(humanize(kv.Key)+": ")+val) + } + return strings.Join(lines, "\n") + case []any: + if len(x) == 0 { + return sty.Muted.Render("[]") + } + var lines []string + for _, item := range x { + lines = append(lines, valueString(field, item, sty, width, depth+1)) + } + return strings.Join(lines, "\n") + default: + return sty.Base.Render(fmt.Sprint(x)) + } +} + +// stringValue styles a string: a resource URI becomes an accent-colored, arrow- +// marked chip; any other URL is dimmed and arrow-marked outward; plain text is +// shown and clamped to the width. +func stringValue(field, s string, sty *styles, width int) string { + if u, err := kit.ParseURI(s); err == nil && !kit.IsReservedKind(u.Scheme) { + label := u.Authority + "/" + u.ID() + return sty.schemeStyle(u.Scheme).Render("→ " + label) + } + if isHTTPURL(s) { + w := width - 2 + if w < 12 { + w = 12 + } + return sty.Muted.Render(truncate(s, w) + " ↗") + } + s = strings.ReplaceAll(s, "\n", " ") + if width > 4 && lipgloss.Width(s) > width { + s = truncate(s, width) + } + return sty.Base.Render(s) +} + +// linkRow is one followable edge in a record's @links, flattened from the grouped +// map into the order the links list and the resource pane render. +type linkRow struct { + Field string + URI kit.URI + Raw string +} + +// flattenLinks turns an envelope's grouped @links into a stable, sorted-by-field +// slice of followable rows, dropping any that fail to parse. +func flattenLinks(m map[string][]string) []linkRow { + if len(m) == 0 { + return nil + } + fields := make([]string, 0, len(m)) + for f := range m { + fields = append(fields, f) + } + sort.Strings(fields) + var out []linkRow + for _, f := range fields { + for _, raw := range m[f] { + u, err := kit.ParseURI(raw) + if err != nil { + continue + } + out = append(out, linkRow{Field: f, URI: u, Raw: raw}) + } + } + return out +} + +// --- small helpers (ported from web/render.go) ------------------------------ + +// humanize turns a json field name into a label: similar_books -> "Similar books". +func humanize(name string) string { + if name == "" { + return name + } + s := strings.NewReplacer("_", " ", "-", " ").Replace(name) + return strings.ToUpper(s[:1]) + s[1:] +} + +// relTime renders an RFC3339 timestamp as a relative string; non-times pass back. +func relTime(ts string) string { + t, err := time.Parse(time.RFC3339, ts) + if err != nil { + return ts + } + d := time.Since(t) + switch { + case d < time.Minute: + return "just now" + case d < time.Hour: + return fmt.Sprintf("%dm ago", int(d.Minutes())) + case d < 24*time.Hour: + return fmt.Sprintf("%dh ago", int(d.Hours())) + default: + return t.Format("2006-01-02 15:04") + } +} + +func isHTTPURL(s string) bool { + return strings.HasPrefix(s, "http://") || strings.HasPrefix(s, "https://") +} + +// groupNumber inserts thousands separators into a plain integer string; it +// leaves decimals and non-numbers untouched. +func groupNumber(s string) string { + neg := strings.HasPrefix(s, "-") + d := strings.TrimPrefix(s, "-") + if d == "" || strings.ContainsAny(d, ".eE") { + return s + } + for _, r := range d { + if r < '0' || r > '9' { + return s + } + } + if len(d) <= 4 { + return s + } + var b strings.Builder + for i, r := range d { + if i > 0 && (len(d)-i)%3 == 0 { + b.WriteByte(',') + } + b.WriteRune(r) + } + if neg { + return "-" + b.String() + } + return b.String() +} + +// truncate clamps s to n runes with an ellipsis, measuring by rune so a multibyte +// string is not cut mid-character. +func truncate(s string, n int) string { + if n <= 1 { + return "…" + } + r := []rune(s) + if len(r) <= n { + return s + } + return string(r[:n-1]) + "…" +} + +// oneLine collapses a multi-line rendering to a single line, for nested-object +// inline display. +func oneLine(s string) string { + if i := strings.IndexByte(s, '\n'); i >= 0 { + return s[:i] + " …" + } + return s +} + +// indentAfterFirst left-pads every line after the first by n spaces, so a +// multi-line value aligns under the value column rather than the key. +func indentAfterFirst(s string, n int) string { + if !strings.Contains(s, "\n") { + return s + } + pad := strings.Repeat(" ", n) + lines := strings.Split(s, "\n") + for i := 1; i < len(lines); i++ { + lines[i] = pad + lines[i] + } + return strings.Join(lines, "\n") +} diff --git a/tui/resource.go b/tui/resource.go new file mode 100644 index 0000000..8ed09d1 --- /dev/null +++ b/tui/resource.go @@ -0,0 +1,381 @@ +package tui + +import ( + "fmt" + "strings" + + "charm.land/bubbles/v2/key" + "charm.land/bubbles/v2/viewport" + tea "charm.land/bubbletea/v2" + + "github.com/tamnd/ant/ant" + "github.com/tamnd/any-cli/kit" +) + +// contentMode is which face of a record the content pane shows. +type contentMode int + +const ( + modeData contentMode = iota + modeBody + modeRaw +) + +func modeName(m contentMode) string { + switch m { + case modeBody: + return "body" + case modeRaw: + return "raw" + default: + return "data" + } +} + +// focusArea is which of the resource screen's two panes has the keyboard. +type focusArea int + +const ( + focusContent focusArea = iota + focusLinks +) + +// resourceScreen is the heart of the program: one dereferenced record. It paints +// instantly from the cache (Lookup) and, on a miss or a refresh, fills in when +// the background Dereference returns. The content pane shows the record's data, +// its rendered body, or its raw JSON; the links pane lists the typed edges, and +// Enter on one follows it (8000_ant_tui §7.2, §8). +type resourceScreen struct { + screenBase + u kit.URI + refresh bool + + f ant.Fetched + loaded bool + fromCache bool + loadErr error + inflight bool + + mode contentMode + focus focusArea + content viewport.Model + links picker + + bodyRendered string + bodyRequested bool + + // content-pane render memo, so the data/raw block re-renders only on a real + // change rather than every frame. + lastW int + lastMode contentMode + contentDirty bool +} + +func newResourceScreen(d *deps, u kit.URI, refresh bool) *resourceScreen { + s := &resourceScreen{ + screenBase: screenBase{d: d}, + u: u, + refresh: refresh, + mode: modeData, + content: viewport.New(), + links: newPicker(), + } + s.links.empty = "No outbound links." + return s +} + +func (s *resourceScreen) Init() tea.Cmd { + if f, ok := s.d.e.Lookup(s.u); ok { + s.install(f) + if !s.refresh { + return nil + } + } + s.inflight = true + return derefCmd(s.d.ctx, s.d.e, s.u, s.refresh) +} + +func (s *resourceScreen) install(f ant.Fetched) { + s.f = f + s.loaded = true + s.loadErr = nil + s.fromCache = f.FromCache + s.links.setItems(linkItemsFrom(f.Env.Links)) + s.contentDirty = true +} + +func (s *resourceScreen) Update(msg tea.Msg) (Screen, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + s.w, s.h = msg.Width, msg.Height + s.contentDirty = true + if s.mode == modeBody { + s.bodyRendered, s.bodyRequested = "", false + return s, s.maybeRenderBody() + } + return s, nil + + case fetchedMsg: + if msg.URI.String() != s.u.String() { + return s, nil + } + s.inflight = false + if msg.Err != nil { + if !s.loaded { + s.loadErr = msg.Err + } + return s, nil + } + s.install(msg.Fetched) + return s, s.maybeRenderBody() + + case renderedMsg: + if msg.Key == renderKey(s.u) { + s.bodyRendered = msg.Markdown + if s.mode == modeBody { + s.contentDirty = true + } + } + return s, nil + + case tea.KeyPressMsg: + return s.onKey(msg) + } + return s, nil +} + +func (s *resourceScreen) onKey(msg tea.KeyPressMsg) (Screen, tea.Cmd) { + k := s.d.keys + switch { + case key.Matches(msg, k.Tab): + if len(s.links.items) > 0 { + if s.focus == focusContent { + s.focus = focusLinks + } else { + s.focus = focusContent + } + } + return s, nil + case key.Matches(msg, k.Mode): + s.cycleMode() + return s, s.maybeRenderBody() + case key.Matches(msg, k.Body): + s.mode = modeBody + s.contentDirty = true + return s, s.maybeRenderBody() + case key.Matches(msg, k.Refresh): + s.refresh, s.inflight = true, true + return s, derefCmd(s.d.ctx, s.d.e, s.u, true) + case key.Matches(msg, k.Links): + return s, push(newLinksScreen(s.d, s.u)) + case key.Matches(msg, k.List): + return s, push(newCollectionScreen(s.d, s.u)) + case key.Matches(msg, k.Graph): + return s, push(newGraphScreen(s.d, s.u, 1)) + case key.Matches(msg, k.URL): + loc, err := s.d.e.URL(s.u) + if err != nil { + return s, toastCmd("no live url: "+err.Error(), true) + } + return s, tea.Batch(tea.SetClipboard(loc), toastCmd("copied live url: "+loc, false)) + case key.Matches(msg, k.Copy): + return s, tea.Batch(tea.SetClipboard(s.u.String()), toastCmd("copied "+s.u.String(), false)) + case key.Matches(msg, k.Export): + return s, tea.Batch(toastCmd("exporting "+s.u.String()+" …", false), exportCmd(s.d.ctx, s.d.e, s.u, 0, false)) + case key.Matches(msg, k.Enter): + if s.focus == focusLinks { + if it, ok := s.links.selected(); ok && it.hasURI { + return s, navigate(it.uri) + } + } + return s, nil + } + if s.focus == focusLinks { + s.links.handle(msg, k) + return s, nil + } + s.contentMotion(msg, k) + return s, nil +} + +func (s *resourceScreen) contentMotion(msg tea.KeyPressMsg, k keyMap) { + switch { + case key.Matches(msg, k.Up): + s.content.ScrollUp(1) + case key.Matches(msg, k.Down): + s.content.ScrollDown(1) + case key.Matches(msg, k.Top): + s.content.GotoTop() + case key.Matches(msg, k.Bottom): + s.content.GotoBottom() + case key.Matches(msg, k.HalfUp): + s.content.HalfPageUp() + case key.Matches(msg, k.HalfDown): + s.content.HalfPageDown() + } +} + +func (s *resourceScreen) cycleMode() { + switch s.mode { + case modeData: + if s.f.HasBody { + s.mode = modeBody + } else { + s.mode = modeRaw + } + case modeBody: + s.mode = modeRaw + default: + s.mode = modeData + } + s.contentDirty = true +} + +// maybeRenderBody kicks off the glamour pass when body mode needs it, off the +// render loop. The result returns as a renderedMsg keyed to this record. +func (s *resourceScreen) maybeRenderBody() tea.Cmd { + if s.mode != modeBody || !s.loaded || !s.f.HasBody || s.bodyRendered != "" || s.bodyRequested { + return nil + } + s.bodyRequested = true + return renderBodyCmd(renderKey(s.u), s.f.Body, s.d.sty.dark, s.w) +} + +func (s *resourceScreen) View(w, h int) string { + s.w, s.h = w, h + sty := s.d.sty + if !s.loaded { + if s.loadErr != nil { + return s.errView(sty, w, h) + } + return sty.Muted.Render("Loading " + s.u.String() + " …") + } + + contentH, linksH := s.layout() + s.content.SetWidth(w) + s.content.SetHeight(contentH) + if s.contentDirty || s.lastW != w || s.lastMode != s.mode { + s.content.SetContent(s.renderContent(w)) + s.lastW, s.lastMode, s.contentDirty = w, s.mode, false + } + + parts := []string{ + s.header(sty, w), + s.sectionTitle(sty, strings.ToUpper(modeName(s.mode)), s.focus == focusContent), + s.content.View(), + } + if linksH > 0 { + s.links.setSize(w, linksH) + parts = append(parts, + s.sectionTitle(sty, fmt.Sprintf("LINKS (%d)", len(s.links.items)), s.focus == focusLinks), + s.links.View(sty)) + } + return strings.Join(parts, "\n") +} + +// layout splits the body height between the content pane and the links pane, +// reserving a line for each section title. +func (s *resourceScreen) layout() (contentH, linksH int) { + remaining := s.h - 2 // header + if remaining < 1 { + remaining = 1 + } + if len(s.links.items) > 0 { + linksH = len(s.links.items) + if cap := max(3, remaining/3); linksH > cap { + linksH = cap + } + contentH = remaining - 2 - linksH + } else { + contentH = remaining - 1 + } + if contentH < 1 { + contentH = 1 + } + return +} + +func (s *resourceScreen) renderContent(width int) string { + sty := s.d.sty + switch s.mode { + case modeRaw: + return renderJSON(s.f.Raw, sty) + case modeBody: + if !s.f.HasBody { + return sty.Muted.Render("(this record has no body)") + } + if s.bodyRendered == "" { + return sty.Muted.Render("rendering …") + } + return s.bodyRendered + default: + return renderData(s.f.Raw, sty, width) + } +} + +func (s *resourceScreen) header(sty *styles, w int) string { + scheme := schemeOf(s.f.Env.Type) + line1 := sty.schemeStyle(scheme).Render("● "+s.f.Env.Type) + " " + sty.Title.Render(s.u.String()) + badge := sty.Muted.Render("live") + if s.fromCache { + badge = sty.OK.Render("cached") + } + line2 := sty.Muted.Render(relTime(s.f.Env.Fetched)) + sty.Crumb.Render(" · ") + badge + return clampLine(line1, w) + "\n" + clampLine(line2, w) +} + +func (s *resourceScreen) sectionTitle(sty *styles, label string, focused bool) string { + if focused { + return sty.Title.Render("▌ " + label) + } + return sty.Muted.Render(" " + label) +} + +func (s *resourceScreen) errView(sty *styles, w, h int) string { + block := sty.Err.Render("Couldn't load "+s.u.String()) + "\n\n" + + sty.Base.Render(s.loadErr.Error()) + "\n\n" + + sty.Muted.Render("r retry · u live url · esc back") + return padLines(block, w, h) +} + +func (s *resourceScreen) Title() string { + if s.u.ID() != "" { + return s.u.Authority + "/" + s.u.ID() + } + return s.u.Authority +} + +func (s *resourceScreen) Subject() (kit.URI, bool) { return s.u, true } +func (s *resourceScreen) Loading() bool { return s.inflight } +func (s *resourceScreen) Cached() bool { return s.loaded && s.fromCache && !s.inflight } + +func (s *resourceScreen) ShortHelp() []key.Binding { + k := s.d.keys + return []key.Binding{k.Tab, k.Enter, k.Mode, k.Links, k.Refresh, k.Copy} +} + +func (s *resourceScreen) FullHelp() [][]key.Binding { + k := s.d.keys + return [][]key.Binding{ + {k.Up, k.Down, k.HalfDown, k.HalfUp, k.Top, k.Bottom}, + {k.Tab, k.Mode, k.Body, k.Enter}, + {k.Links, k.List, k.Graph, k.URL, k.Copy, k.Export, k.Refresh}, + } +} + +// linkItemsFrom turns an envelope's grouped @links into picker rows, labeled by +// target with the source field as the dim subtitle. +func linkItemsFrom(m map[string][]string) []pickItem { + rows := flattenLinks(m) + items := make([]pickItem, 0, len(rows)) + for _, r := range rows { + items = append(items, pickItem{ + title: r.URI.Authority + "/" + r.URI.ID(), + subtitle: humanize(r.Field), + scheme: r.URI.Scheme, + uri: r.URI, + hasURI: true, + }) + } + return items +} diff --git a/tui/run.go b/tui/run.go new file mode 100644 index 0000000..aa8eaff --- /dev/null +++ b/tui/run.go @@ -0,0 +1,50 @@ +// Package tui is the ant terminal console: a full-screen, keyboard-driven +// browser over the whole resource-URI namespace, built on Bubble Tea v2. It is +// the third human surface beside the CLI and the web console, and like the web +// console it adds no data capability of its own: every screen is a thin render of +// an ant.Engine method, reached through the Deref seam, so the program is fully +// testable against a network-free fake (8000_ant_tui §6, §16). +package tui + +import ( + "context" + + tea "charm.land/bubbletea/v2" + + "github.com/tamnd/any-cli/kit" +) + +// Build is the binary's release identity, surfaced on the dashboard and the help +// overlay. It mirrors web.Build so the command wiring passes the same value. +type Build struct { + Version string + Commit string + Date string +} + +// Run starts the terminal console over e. If initial is non-empty it is resolved +// (offline, the same Resolve the omnibox uses) and opened on launch, so +// `ant tui goodreads://book/2767052` lands straight on the record. Run blocks +// until the user quits or ctx is cancelled by the signal handler. +func Run(ctx context.Context, e Deref, b Build, initial string) error { + d := &deps{ + e: e, + ctx: ctx, + keys: newKeys(), + sty: newStyles(true), // refined on the first BackgroundColorMsg + build: b, + } + + var initURI *kit.URI + if initial != "" { + u, err := e.Resolve(initial, "") + if err != nil { + return err + } + initURI = &u + } + + p := tea.NewProgram(newApp(d, initURI), tea.WithContext(ctx)) + _, err := p.Run() + return err +} diff --git a/tui/screen.go b/tui/screen.go new file mode 100644 index 0000000..f06eb21 --- /dev/null +++ b/tui/screen.go @@ -0,0 +1,95 @@ +package tui + +import ( + "context" + "strconv" + "strings" + + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" + + "github.com/tamnd/any-cli/kit" +) + +// deps is the shared environment every screen is born with: the Deref it reads +// through, the program context that cancels its IO, the keymap, and a pointer to +// the live styles. The App owns one deps and hands it to each screen +// constructor; a theme toggle mutates deps.sty in place, so every screen, even +// one resting in the back-stack, repaints in the new palette (8000_ant_tui §13). +type deps struct { + e Deref + ctx context.Context + keys keyMap + sty *styles + build Build +} + +// Screen is one place in the program: a render plus the Update that drives it. +// It is the unit the App pushes and pops. A screen owns its own scroll position +// and loaded data, so going back restores it exactly as left, the place k9s +// loses (8000_ant_tui §5.2, §12). +type Screen interface { + // Init returns the command that kicks off the screen's first load. The App + // runs it after sizing the screen. + Init() tea.Cmd + // Update advances the screen and returns its next self plus any command. A + // screen never mutates the stack directly: it asks via navigate/push/back + // messages carried on the returned command. + Update(tea.Msg) (Screen, tea.Cmd) + // View renders the screen body into the given content box (the area between + // the title and status bars). + View(width, height int) string + // Title is the short label shown in the title bar and breadcrumb. + Title() string + // Subject is the URI this screen is about, if any, for the omnibox default + // and the copy/url/graph actions. + Subject() (kit.URI, bool) + // Loading reports whether a fetch this screen issued is still in flight, so + // the App shows the spinner. + Loading() bool + // Cached reports whether the screen is showing a record served from the + // on-disk cache, so the title bar can badge it (8000_ant_tui §4). + Cached() bool + // Capturing reports that the screen wants raw key input (a text field is + // focused), so the App routes every key to it and suspends the global keymap, + // the same deal the omnibox gets while open (8000_ant_tui §10.3). + Capturing() bool + // ShortHelp is the footer key hints; FullHelp the help overlay grid. + ShortHelp() []key.Binding + FullHelp() [][]key.Binding +} + +// screenBase carries the fields and the default method bodies every screen +// shares, so a screen file holds only what makes it distinct. A screen embeds it +// and overrides the methods it actually implements (Loading, Cached, Subject, +// Capturing) as needed. +type screenBase struct { + d *deps + w, h int +} + +func (s *screenBase) Capturing() bool { return false } +func (s *screenBase) Cached() bool { return false } +func (s *screenBase) Loading() bool { return false } +func (s *screenBase) Subject() (kit.URI, bool) { return kit.URI{}, false } + +// --- navigation command builders -------------------------------------------- +// +// Screens request a stack change by returning one of these commands; the App is +// the only code that mutates the stack (8000_ant_tui §5.1). + +func navigate(u kit.URI) tea.Cmd { return func() tea.Msg { return navigateMsg{URI: u} } } +func push(s Screen) tea.Cmd { return func() tea.Msg { return pushMsg{Screen: s} } } +func goBack() tea.Cmd { return func() tea.Msg { return backMsg{} } } + +// itoa is a tiny strconv.Itoa alias used by the fetch-key builders. +func itoa(n int) string { return strconv.Itoa(n) } + +// schemeOf pulls the leading scheme out of a record type ("demo/widget" -> +// "demo") for the accent and badge color. +func schemeOf(typ string) string { + if i := strings.IndexByte(typ, '/'); i >= 0 { + return typ[:i] + } + return typ +} diff --git a/tui/screen_test.go b/tui/screen_test.go new file mode 100644 index 0000000..53e9b56 --- /dev/null +++ b/tui/screen_test.go @@ -0,0 +1,307 @@ +package tui + +import ( + "strings" + "testing" + + tea "charm.land/bubbletea/v2" +) + +// findMsg returns the first message of type T in msgs. +func findMsg[T any](msgs []tea.Msg) (T, bool) { + for _, m := range msgs { + if t, ok := m.(T); ok { + return t, true + } + } + var zero T + return zero, false +} + +func TestDashboardListsAndOpens(t *testing.T) { + var s Screen = newDashboardScreen(testDeps(fakeDeref{})) + s = sized(s, 80, 24) + if out := s.View(80, 24); !strings.Contains(out, "demo") { + t.Fatalf("dashboard should list the demo domain:\n%s", out) + } + + _, cmd := s.Update(kEnter) + if pm, ok := findMsg[pushMsg](drain(cmd)); !ok { + t.Fatal("enter on a domain should push a screen") + } else if _, ok := pm.Screen.(*domainScreen); !ok { + t.Fatalf("enter should push a domain screen, got %T", pm.Screen) + } + + _, cmd = s.Update(kRune('b')) + if pm, ok := findMsg[pushMsg](drain(cmd)); !ok { + t.Fatal("b should push the browse screen") + } else if _, ok := pm.Screen.(*browseScreen); !ok { + t.Fatalf("b should push a browse screen, got %T", pm.Screen) + } +} + +func TestResourceWarmPaintsWithoutLoading(t *testing.T) { + s := newResourceScreen(testDeps(fakeDeref{}), mustURI("demo://widget/42"), false) + _ = sized(s, 100, 30) + cmd := s.Init() + if s.Loading() { + t.Fatal("a warm record (cache hit) should not be loading after Init") + } + if !s.Cached() { + t.Fatal("a warm record should report Cached") + } + if cmd != nil { + t.Fatal("warm, non-refresh Init should issue no command") + } + out := s.View(100, 30) + if !strings.Contains(out, "demo://widget/42") { + t.Fatalf("resource view should show the URI:\n%s", out) + } +} + +func TestResourceColdDerefsThenInstalls(t *testing.T) { + s := newResourceScreen(testDeps(fakeDeref{cold: true}), mustURI("demo://widget/42"), false) + _ = sized(s, 100, 30) + cmd := s.Init() + if !s.Loading() { + t.Fatal("a cold record should be loading after Init") + } + fm, ok := findMsg[fetchedMsg](drain(cmd)) + if !ok { + t.Fatal("cold Init should issue a deref command") + } + ns, _ := s.Update(fm) + if ns.Loading() { + t.Fatal("resource should stop loading once the fetch lands") + } + if out := ns.View(100, 30); !strings.Contains(out, "demo/widget") { + t.Fatalf("installed resource should render its type:\n%s", out) + } +} + +func TestResourceModeCycle(t *testing.T) { + s := newResourceScreen(testDeps(fakeDeref{}), mustURI("demo://widget/42"), false) + _ = sized(s, 100, 30) + s.Init() + if s.mode != modeData { + t.Fatalf("resource should open in data mode, got %v", s.mode) + } + s.Update(kRune('v')) // data -> body (HasBody) + if s.mode != modeBody { + t.Fatalf("v should cycle to body, got %v", s.mode) + } + s.Update(kRune('v')) // body -> raw + if s.mode != modeRaw { + t.Fatalf("v should cycle to raw, got %v", s.mode) + } + s.Update(kRune('v')) // raw -> data + if s.mode != modeData { + t.Fatalf("v should cycle back to data, got %v", s.mode) + } +} + +func TestResourceCopyToast(t *testing.T) { + s := newResourceScreen(testDeps(fakeDeref{}), mustURI("demo://widget/42"), false) + _ = sized(s, 100, 30) + s.Init() + _, cmd := s.Update(kRune('y')) + tm, ok := findMsg[toastMsg](drain(cmd)) + if !ok { + t.Fatal("y should flash a copy toast") + } + if !strings.Contains(tm.Text, "demo://widget/42") { + t.Fatalf("copy toast should name the URI, got %q", tm.Text) + } +} + +func TestResourceGraphAndLinksKeys(t *testing.T) { + s := newResourceScreen(testDeps(fakeDeref{}), mustURI("demo://widget/42"), false) + _ = sized(s, 100, 30) + s.Init() + + _, cmd := s.Update(kRune('x')) + if pm, ok := findMsg[pushMsg](drain(cmd)); !ok { + t.Fatal("x should push a screen") + } else if _, ok := pm.Screen.(*graphScreen); !ok { + t.Fatalf("x should push the graph screen, got %T", pm.Screen) + } + + _, cmd = s.Update(kRune('L')) + if pm, ok := findMsg[pushMsg](drain(cmd)); !ok { + t.Fatal("L should push a screen") + } else if _, ok := pm.Screen.(*linksScreen); !ok { + t.Fatalf("L should push the links screen, got %T", pm.Screen) + } +} + +func TestResourceFollowLink(t *testing.T) { + s := newResourceScreen(testDeps(fakeDeref{}), mustURI("demo://widget/42"), false) + _ = sized(s, 100, 30) + s.Init() + s.Update(kTab) // focus the links pane + if s.focus != focusLinks { + t.Fatal("tab should move focus to the links pane") + } + _, cmd := s.Update(kEnter) + nm, ok := findMsg[navigateMsg](drain(cmd)) + if !ok { + t.Fatal("enter on a focused link should navigate") + } + if nm.URI.String() != "demo://maker/m1" { + t.Fatalf("should follow the maker link, got %s", nm.URI) + } +} + +func TestCollectionListsAndFollows(t *testing.T) { + var s Screen = newCollectionScreen(testDeps(fakeDeref{}), mustURI("demo://maker/m1")) + s = sized(s, 80, 24) + lm, ok := findMsg[listedMsg](drain(s.Init())) + if !ok { + t.Fatal("collection Init should issue a list command") + } + s, _ = s.Update(lm) + if out := s.View(80, 24); !strings.Contains(out, "widget/1") { + t.Fatalf("collection should list members:\n%s", out) + } + _, cmd := s.Update(kEnter) + if _, ok := findMsg[navigateMsg](drain(cmd)); !ok { + t.Fatal("enter on a member should navigate") + } +} + +func TestSearchEditRunFollow(t *testing.T) { + d := testDeps(fakeDeref{}) + s := newSearchScreen(d, "demo") + _ = sized(s, 80, 24) + s.Init() + if !s.Capturing() { + t.Fatal("search should capture keys while the query field is focused") + } + s.Update(kRune('h')) + s.Update(kRune('i')) + _, cmd := s.Update(kEnter) + sm, ok := findMsg[searchedMsg](drain(cmd)) + if !ok { + t.Fatal("enter in the query field should run a search") + } + if s.Capturing() { + t.Fatal("running a query should release the capture") + } + s.Update(sm) + _, cmd = s.Update(kEnter) + if _, ok := findMsg[navigateMsg](drain(cmd)); !ok { + t.Fatal("enter on a result should navigate") + } +} + +func TestSearchSeededQueryRunsOnInit(t *testing.T) { + s := newSearchScreenWith(testDeps(fakeDeref{}), "demo", "rockets") + _ = sized(s, 80, 24) + sm, ok := findMsg[searchedMsg](drain(s.Init())) + if !ok { + t.Fatal("a seeded search should run on Init") + } + if sm.Query != "rockets" { + t.Fatalf("seeded query should be carried through, got %q", sm.Query) + } + if s.Capturing() { + t.Fatal("a seeded search should open on results, not the field") + } +} + +func TestGraphTreeAndDotToggle(t *testing.T) { + s := newGraphScreen(testDeps(fakeDeref{}), mustURI("demo://widget/42"), 1) + _ = sized(s, 80, 24) + wm, ok := findMsg[walkedMsg](drain(s.Init())) + if !ok { + t.Fatal("graph Init should issue a walk command") + } + s.Update(wm) + if len(s.pick.items) != 2 { + t.Fatalf("graph should list both nodes, got %d", len(s.pick.items)) + } + if out := s.View(80, 24); !strings.Contains(out, "tree") { + t.Fatalf("graph should open in tree view:\n%s", out) + } + s.Update(kRune('v')) + if !s.dotMode { + t.Fatal("v should toggle the dot view") + } + if out := s.View(80, 24); !strings.Contains(out, "digraph") { + t.Fatalf("dot view should render the DOT:\n%s", out) + } +} + +func TestGraphEnterFollowsNode(t *testing.T) { + s := newGraphScreen(testDeps(fakeDeref{}), mustURI("demo://widget/42"), 1) + _ = sized(s, 80, 24) + wm, _ := findMsg[walkedMsg](drain(s.Init())) + s.Update(wm) + s.Update(kDown) // move to the second node + _, cmd := s.Update(kEnter) + if _, ok := findMsg[navigateMsg](drain(cmd)); !ok { + t.Fatal("enter on a node should navigate to it") + } +} + +func TestBrowseRootFoldersWithCounts(t *testing.T) { + var s Screen = newBrowseScreen(testDeps(fakeDeref{}), "") + s = sized(s, 80, 24) + msgs := drain(s.Init()) + lm, ok := findMsg[llMsg](msgs) + if !ok { + t.Fatal("browse root should probe each domain") + } + s, _ = s.Update(lm) + out := s.View(80, 24) + if !strings.Contains(out, "demo") || !strings.Contains(out, "cached") { + t.Fatalf("browse root should show domains with cache counts:\n%s", out) + } + _, cmd := s.Update(kEnter) + if pm, ok := findMsg[pushMsg](drain(cmd)); !ok { + t.Fatal("enter on a domain folder should descend") + } else if bs, ok := pm.Screen.(*browseScreen); !ok || bs.prefix != "demo://" { + t.Fatalf("descending should push browse of demo://, got %T %q", pm.Screen, prefixOf(pm.Screen)) + } +} + +func TestBrowsePrefixGroupsRecords(t *testing.T) { + var s Screen = newBrowseScreen(testDeps(fakeDeref{}), "demo://maker") + s = sized(s, 80, 24) + lm, ok := findMsg[llMsg](drain(s.Init())) + if !ok { + t.Fatal("a browse prefix should list its cached URIs") + } + s, _ = s.Update(lm) + if out := s.View(80, 24); !strings.Contains(out, "maker/m1") { + t.Fatalf("browse should surface the cached record as a leaf:\n%s", out) + } + _, cmd := s.Update(kEnter) + if _, ok := findMsg[navigateMsg](drain(cmd)); !ok { + t.Fatal("enter on a record leaf should navigate") + } +} + +func prefixOf(s Screen) string { + if b, ok := s.(*browseScreen); ok { + return b.prefix + } + return "" +} + +func TestDomainOpensSearchAndBrowse(t *testing.T) { + var s Screen = newDomainScreen(testDeps(fakeDeref{}), "demo") + s = sized(s, 80, 24) + _, cmd := s.Update(kRune('/')) + if pm, ok := findMsg[pushMsg](drain(cmd)); !ok { + t.Fatal("/ on a searchable domain should push search") + } else if _, ok := pm.Screen.(*searchScreen); !ok { + t.Fatalf("/ should push the search screen, got %T", pm.Screen) + } + _, cmd = s.Update(kRune('b')) + if pm, ok := findMsg[pushMsg](drain(cmd)); !ok { + t.Fatal("b should push browse") + } else if bs, ok := pm.Screen.(*browseScreen); !ok || bs.prefix != "demo://" { + t.Fatalf("b should browse demo://, got %T", pm.Screen) + } +} diff --git a/tui/search.go b/tui/search.go new file mode 100644 index 0000000..d1c5ae4 --- /dev/null +++ b/tui/search.go @@ -0,0 +1,155 @@ +package tui + +import ( + "strings" + + "charm.land/bubbles/v2/key" + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" +) + +// searchListN is how many hits a search asks for. +const searchListN = 25 + +// searchScreen runs a domain's free-text search (ant search --on ): a +// query field on top, followable results below. While the field is focused the +// screen Captures keys, so typing a query never trips the global keymap +// (8000_ant_tui §7.5, §10.3). +type searchScreen struct { + screenBase + scheme string + query string + ti textinput.Model + pick picker + editing bool + inflight bool + err error + searched bool + pending string // a query to run on Init (from the omnibox :search verb) +} + +func newSearchScreen(d *deps, scheme string) *searchScreen { + ti := textinput.New() + ti.Prompt = "" + ti.SetVirtualCursor(true) + ti.Placeholder = "type a query, enter to run" + s := &searchScreen{screenBase: screenBase{d: d}, scheme: scheme, ti: ti, pick: newPicker()} + s.pick.empty = "No results yet." + return s +} + +// newSearchScreenWith opens search already aimed at a query, for the omnibox +// ':search ' verb: it runs the query on Init instead of waiting +// for the field. +func newSearchScreenWith(d *deps, scheme, query string) *searchScreen { + s := newSearchScreen(d, scheme) + if q := strings.TrimSpace(query); q != "" { + s.ti.SetValue(q) + s.pending = q + } + return s +} + +func (s *searchScreen) Init() tea.Cmd { + if s.pending != "" { + q := s.pending + s.pending = "" + s.query, s.inflight, s.searched, s.err = q, true, true, nil + return searchCmd(s.d.ctx, s.d.e, s.scheme, q, searchListN) + } + s.editing = true + return s.ti.Focus() +} + +func (s *searchScreen) Update(msg tea.Msg) (Screen, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + s.w, s.h = msg.Width, msg.Height + if s.w > 4 { + s.ti.SetWidth(s.w - 4) + } + case searchedMsg: + if msg.Key != searchKey(s.scheme, searchListN, s.query) { + return s, nil + } + s.inflight = false + if msg.Err != nil { + s.err = msg.Err + return s, nil + } + s.pick.setItems(envItems(msg.Envs)) + case tea.KeyPressMsg: + return s.onKey(msg) + } + return s, nil +} + +func (s *searchScreen) onKey(msg tea.KeyPressMsg) (Screen, tea.Cmd) { + k := s.d.keys + if s.editing { + switch { + case key.Matches(msg, k.Escape): + s.editing = false + s.ti.Blur() + return s, nil + case key.Matches(msg, k.Enter): + q := s.ti.Value() + s.editing = false + s.ti.Blur() + if q == "" { + return s, nil + } + s.query, s.inflight, s.searched, s.err = q, true, true, nil + return s, searchCmd(s.d.ctx, s.d.e, s.scheme, q, searchListN) + default: + var cmd tea.Cmd + s.ti, cmd = s.ti.Update(msg) + return s, cmd + } + } + switch { + case key.Matches(msg, k.Filter): + s.editing = true + return s, s.ti.Focus() + case key.Matches(msg, k.Enter): + if it, ok := s.pick.selected(); ok && it.hasURI { + return s, navigate(it.uri) + } + } + s.pick.handle(msg, k) + return s, nil +} + +func (s *searchScreen) View(w, h int) string { + sty := s.d.sty + label := sty.Title.Render("search "+s.scheme) + sty.Crumb.Render(" › ") + field := label + s.ti.View() + var status string + switch { + case s.err != nil: + status = sty.Err.Render(s.err.Error()) + case s.inflight: + status = sty.Muted.Render("searching …") + case s.searched: + status = sty.Muted.Render("enter to open · / to edit query") + default: + status = sty.Muted.Render("type a query and press enter") + } + s.pick.setSize(w, max(1, h-3)) + return field + "\n" + status + "\n" + s.pick.View(sty) +} + +func (s *searchScreen) Title() string { return "search " + s.scheme } +func (s *searchScreen) Capturing() bool { return s.editing } +func (s *searchScreen) Loading() bool { return s.inflight } +func (s *searchScreen) ShortHelp() []key.Binding { + k := s.d.keys + return []key.Binding{k.Filter, k.Enter, k.Down, k.Back} +} +func (s *searchScreen) FullHelp() [][]key.Binding { + k := s.d.keys + return [][]key.Binding{ + {k.Filter, k.Enter}, + {k.Up, k.Down, k.Top, k.Bottom, k.Back}, + } +} diff --git a/tui/styles.go b/tui/styles.go new file mode 100644 index 0000000..e4fdbb0 --- /dev/null +++ b/tui/styles.go @@ -0,0 +1,94 @@ +package tui + +import ( + "hash/fnv" + "image/color" + + "charm.land/lipgloss/v2" +) + +// styles is the resolved palette and the named lipgloss styles every screen +// draws with. It is built once from the detected background (8000_ant_tui §3.4) +// and rebuilt only when the theme is toggled, so screens never touch color +// literals directly: a theme change is one place. +type styles struct { + dark bool + + fg color.Color + muted color.Color + border color.Color + surface color.Color + accent color.Color + errc color.Color + okc color.Color + + Base lipgloss.Style // body text + Muted lipgloss.Style // secondary text, crumbs + Title lipgloss.Style // bold heading + Key lipgloss.Style // a kv field name + Err lipgloss.Style // an error line + OK lipgloss.Style // a success line / cache badge + Sel lipgloss.Style // the cursor row + Crumb lipgloss.Style // a breadcrumb segment + Pane lipgloss.Style // an unfocused bordered pane + PaneFocus lipgloss.Style // the focused bordered pane (accent border) +} + +// newStyles resolves the palette through lipgloss.LightDark and builds the named +// styles. dark comes from lipgloss.HasDarkBackground at startup (§13.1). +func newStyles(dark bool) *styles { + ld := lipgloss.LightDark(dark) + c := func(light, darkc string) color.Color { return ld(lipgloss.Color(light), lipgloss.Color(darkc)) } + + s := &styles{dark: dark} + s.fg = c("#18181b", "#fafafa") + s.muted = c("#71717a", "#a1a1aa") + s.border = c("#d4d4d8", "#3f3f46") + s.surface = c("#f4f4f5", "#27272a") + s.accent = c("#18181b", "#fafafa") + s.errc = c("#dc2626", "#f87171") + s.okc = c("#16a34a", "#4ade80") + + s.Base = lipgloss.NewStyle().Foreground(s.fg) + s.Muted = lipgloss.NewStyle().Foreground(s.muted) + s.Title = lipgloss.NewStyle().Foreground(s.fg).Bold(true) + s.Key = lipgloss.NewStyle().Foreground(s.muted) + s.Err = lipgloss.NewStyle().Foreground(s.errc) + s.OK = lipgloss.NewStyle().Foreground(s.okc) + s.Crumb = lipgloss.NewStyle().Foreground(s.muted) + s.Sel = lipgloss.NewStyle().Foreground(s.fg).Background(s.surface).Bold(true) + s.Pane = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(s.border) + s.PaneFocus = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(s.accent) + return s +} + +// schemeHex mirrors the web console's per-domain accent map (8000_ant_serve §3.1) +// so the two surfaces agree on a domain's color. An unknown driver hashes into a +// stable fallback hue. +var schemeHex = map[string]string{ + "goodreads": "#d97706", "x": "#0ea5e9", "wikipedia": "#a1a1aa", + "youtube": "#dc2626", "reddit": "#ea580c", "facebook": "#3b82f6", + "bilibili": "#ec4899", "amazon": "#f59e0b", "archive": "#22c55e", + "threads": "#a78bfa", "douban": "#16a34a", "xiaohongshu": "#f43f5e", +} + +var fallbackPalette = []string{ + "#0ea5e9", "#22c55e", "#f59e0b", "#a78bfa", + "#ec4899", "#14b8a6", "#f43f5e", "#84cc16", +} + +// schemeColor resolves a scheme to its accent color: the fixed map, else a stable +// hash into the fallback palette so an unknown driver still gets a consistent dot. +func schemeColor(scheme string) color.Color { + if hex, ok := schemeHex[scheme]; ok { + return lipgloss.Color(hex) + } + h := fnv.New32a() + _, _ = h.Write([]byte(scheme)) + return lipgloss.Color(fallbackPalette[h.Sum32()%uint32(len(fallbackPalette))]) +} + +// schemeStyle is a foreground style in a scheme's accent, for badges and URIs. +func (s *styles) schemeStyle(scheme string) lipgloss.Style { + return lipgloss.NewStyle().Foreground(schemeColor(scheme)) +}