From bb13885f258f43426457e21e4e67ab221d52f77d Mon Sep 17 00:00:00 2001 From: Tam Nguyen Duc <1218621+tamnd@users.noreply.github.com> Date: Sun, 14 Jun 2026 21:09:53 +0700 Subject: [PATCH 1/3] Add ant tui, a terminal browser over the URI namespace The third human surface beside the CLI and the web console. tui opens a full-screen, keyboard-driven browser over the whole resource-URI namespace, built on Bubble Tea v2. 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 a Deref seam, so the program is fully testable against a network-free fake. Screens: dashboard (domain index), domain home, resource (the dereferenced record with data/body/raw modes and a links pane), collection (ls members), links, search, graph (indented tree plus a DOT view), and browse (the on-disk cache as a navigable tree). An omnibox (:) resolves a bare id, handle, URL, or URI, and understands the browse/domain/search/ls/graph/links verbs. The App owns a back/forward screen stack so going back restores a screen exactly as it was left. Reads are cache-first: a screen paints instantly from Lookup and fills in from a background Dereference on a miss or refresh, with all IO off the render loop. Wire it as `ant tui [uri]` between serve and mcp. --- README.md | 1 + cli/root.go | 1 + cli/tui.go | 41 ++++ docs/content/reference/cli.md | 1 + go.mod | 41 +++- go.sum | 83 ++++++-- tui/app.go | 351 +++++++++++++++++++++++++++++++ tui/browse.go | 318 ++++++++++++++++++++++++++++ tui/collection.go | 83 ++++++++ tui/components.go | 233 +++++++++++++++++++++ tui/dashboard.go | 83 ++++++++ tui/deref.go | 118 +++++++++++ tui/domain.go | 153 ++++++++++++++ tui/fake_test.go | 160 ++++++++++++++ tui/flow_test.go | 139 +++++++++++++ tui/graph.go | 196 +++++++++++++++++ tui/help.go | 69 ++++++ tui/keys.go | 86 ++++++++ tui/links.go | 84 ++++++++ tui/messages.go | 103 +++++++++ tui/omnibox.go | 140 +++++++++++++ tui/render_json.go | 45 ++++ tui/render_markdown.go | 64 ++++++ tui/render_test.go | 66 ++++++ tui/render_value.go | 335 ++++++++++++++++++++++++++++++ tui/resource.go | 381 ++++++++++++++++++++++++++++++++++ tui/run.go | 50 +++++ tui/screen.go | 99 +++++++++ tui/screen_test.go | 307 +++++++++++++++++++++++++++ tui/search.go | 155 ++++++++++++++ tui/styles.go | 94 +++++++++ 31 files changed, 4050 insertions(+), 30 deletions(-) create mode 100644 cli/tui.go create mode 100644 tui/app.go create mode 100644 tui/browse.go create mode 100644 tui/collection.go create mode 100644 tui/components.go create mode 100644 tui/dashboard.go create mode 100644 tui/deref.go create mode 100644 tui/domain.go create mode 100644 tui/fake_test.go create mode 100644 tui/flow_test.go create mode 100644 tui/graph.go create mode 100644 tui/help.go create mode 100644 tui/keys.go create mode 100644 tui/links.go create mode 100644 tui/messages.go create mode 100644 tui/omnibox.go create mode 100644 tui/render_json.go create mode 100644 tui/render_markdown.go create mode 100644 tui/render_test.go create mode 100644 tui/render_value.go create mode 100644 tui/resource.go create mode 100644 tui/run.go create mode 100644 tui/screen.go create mode 100644 tui/screen_test.go create mode 100644 tui/search.go create mode 100644 tui/styles.go 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/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..e72253a 100644 --- a/go.sum +++ b/go.sum @@ -1,40 +1,66 @@ -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.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= +github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +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 +76,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 +101,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 +139,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 +199,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..cdebf0e --- /dev/null +++ b/tui/render_value.go @@ -0,0 +1,335 @@ +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") + } +} + +// shortID is the last path segment of a URI, for compact graph/card labels. +func shortID(uri string) string { + u, err := kit.ParseURI(uri) + if err != nil { + return uri + } + if len(u.Path) == 0 { + return u.Authority + } + return u.Path[len(u.Path)-1] +} + +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..f593146 --- /dev/null +++ b/tui/screen.go @@ -0,0 +1,99 @@ +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 navigateFresh(u kit.URI) tea.Cmd { + return func() tea.Msg { return navigateMsg{URI: u, Refresh: true} } +} +func push(s Screen) tea.Cmd { return func() tea.Msg { return pushMsg{Screen: s} } } +func replace(s Screen) tea.Cmd { return func() tea.Msg { return replaceMsg{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)) +} From 7e1967756a75fe6afda12a10806eabfb5d4c1c52 Mon Sep 17 00:00:00 2001 From: Tam Nguyen Duc <1218621+tamnd@users.noreply.github.com> Date: Sun, 14 Jun 2026 21:16:30 +0700 Subject: [PATCH 2/3] Drop dead helpers and tidy go.sum to satisfy CI The linter flagged three unused functions left over from earlier drafts: shortID, navigateFresh, and the replace command builder (the resource refresh issues its dereference directly, and screens never replaced in place). Remove them. Also run go mod tidy, which prunes the stale cellbuf v0.0.13 sum lines superseded by v0.0.15. --- go.sum | 2 -- tui/render_value.go | 12 ------------ tui/screen.go | 8 ++------ 3 files changed, 2 insertions(+), 20 deletions(-) diff --git a/go.sum b/go.sum index e72253a..951eb38 100644 --- a/go.sum +++ b/go.sum @@ -36,8 +36,6 @@ github.com/charmbracelet/ultraviolet v0.0.0-20260525132238-948f4557a654 h1:FpSYh 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.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= -github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= 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= diff --git a/tui/render_value.go b/tui/render_value.go index cdebf0e..47ba430 100644 --- a/tui/render_value.go +++ b/tui/render_value.go @@ -253,18 +253,6 @@ func relTime(ts string) string { } } -// shortID is the last path segment of a URI, for compact graph/card labels. -func shortID(uri string) string { - u, err := kit.ParseURI(uri) - if err != nil { - return uri - } - if len(u.Path) == 0 { - return u.Authority - } - return u.Path[len(u.Path)-1] -} - func isHTTPURL(s string) bool { return strings.HasPrefix(s, "http://") || strings.HasPrefix(s, "https://") } diff --git a/tui/screen.go b/tui/screen.go index f593146..f06eb21 100644 --- a/tui/screen.go +++ b/tui/screen.go @@ -79,12 +79,8 @@ func (s *screenBase) Subject() (kit.URI, bool) { return kit.URI{}, false } // 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 navigateFresh(u kit.URI) tea.Cmd { - return func() tea.Msg { return navigateMsg{URI: u, Refresh: true} } -} -func push(s Screen) tea.Cmd { return func() tea.Msg { return pushMsg{Screen: s} } } -func replace(s Screen) tea.Cmd { return func() tea.Msg { return replaceMsg{Screen: s} } } -func goBack() tea.Cmd { return func() tea.Msg { return backMsg{} } } +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) } From 2b668457628662ff808b625a8052473ffcc01bdf Mon Sep 17 00:00:00 2001 From: Tam Nguyen Duc <1218621+tamnd@users.noreply.github.com> Date: Sun, 14 Jun 2026 22:10:49 +0700 Subject: [PATCH 3/3] Add v0.3.0 release notes for the terminal console Document ant tui in the docs release-notes section, matching the v0.2.0 narrative: the screens, the omnibox, the back/forward stack, and the cache-first read path it shares with the web console. Bump the v0.2.0 page weight so the newest notes sort first. --- docs/content/release-notes/v0.2.0.md | 2 +- docs/content/release-notes/v0.3.0.md | 87 ++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+), 1 deletion(-) create mode 100644 docs/content/release-notes/v0.3.0.md 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 +```