Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 23 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,12 @@ the auto-repeat heuristic.

| Key | Action |
| --- | --- |
| Tab / Shift+Tab | focus navigation, page content, or actions; move between form controls |
| Ctrl+N | toggle navigation |
| Tab | next item, action, form control, or dialog button |
| Shift+Tab | previous item, action, form control, or dialog button |
| R / P / A / U | Refresh / Play / Add / Uninstall the selected game |
| / (Marketplace/Library) | jump to the Search row |
| Ctrl+U (search) | clear the current query |
| ↑/↓ | select a row or pause-menu item |
| ←/→ (actions) | select the focused action |
| enter | activate a focused item/button; ordinary game rows open details |
Expand All @@ -94,10 +99,11 @@ the auto-repeat heuristic.
| Ctrl+C | quit and save local scores |

While a game runs, game inputs cannot operate the sidebar. Pause first, then
Tab into navigation. The sidebar is 22 columns wide at 120+ terminal columns;
below that it overlays the content on demand. Non-game content is capped at
100 columns. Gameplay keeps its fixed cell footprint and hides the sidebar
when needed to fit. See [TUI workflows](docs/tui.md) for the complete contract.
Ctrl+N into navigation. When open, the 22-column sidebar pushes content at 120+
terminal columns and overlays it on smaller terminals. Closing it restores the
full content area. Non-game content is capped at 100 columns. Gameplay keeps
its fixed cell footprint; navigation overlays rather than squeezing a game that
would not fit beside it. See [TUI workflows](docs/tui.md) for the complete contract.

High scores persist to `~/.config/termcade/scores.json`, and are yours whether
or not you have an account — see [Your history](#your-history).
Expand All @@ -109,14 +115,24 @@ Library, owner/game pages, and account/settings destinations. Browsing is
anonymous. Library joins account games with installed packages, with clear
installation/membership badges and Continue Playing entries.

Both lists have search by name, slug, or description (not owner). Marketplace
searches the registry after 300ms without typing and follows all result pages;
Library filters its displayed entries, including local-only games. Queries are
remembered separately until cleared or the TUI exits. Searching hides Continue
Playing, not sidebar games or account membership. Marketplace queries are limited
by the API to 64 UTF-8 bytes after trimming.

In the **TUI**, Add saves account membership without downloading. Play installs
a missing compatible account game; healthy installed games remain playable
offline. Remove from Library changes only account membership; Uninstall here
removes only the local package, after confirmation. No automatic updates replace
an existing local copy.

Sign in from Settings without exiting the TUI: it shows the trusted pairing URL
and one-time code, and opens a browser only when you choose Open browser.
Sign in from the bottom nav item or Settings without exiting the TUI: a dialog
centers the one-time code and trusted pairing URL (`/pair/ABCD-EFGH`, a
clickable terminal link) and opens a browser only when you choose Open browser.
The pair page looks up that code automatically after sign-in; approval stays
explicit. Esc or Cancel returns to the underlying screen.
Account authentication stays in the browser. Settings also supports handles,
org/member administration, CLI-session revocation, account deletion, and
revoke-then-clear sign-out. The normal pairing destination remains
Expand Down
62 changes: 56 additions & 6 deletions docs/tui.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,24 +7,58 @@ backend behavior, game ABI, and sandbox limits are not changed by this shell.
## Navigation and focus

- Marketplace is the initial destination, including while signed out.
- Tab/Shift+Tab cycles navigation, content, and actions. Arrows select within the
focused area; Enter activates. A normal game row opens details; Continue
Playing and the explicit Play action launch the game.
- At 120 or more columns the sidebar takes 22 columns. At smaller widths it is
an on-demand overlay. Non-game content is centered and capped at 100 columns.
- The navigation's Sign in / @username item is pinned at the bottom of the
rail. Navigation has no local divider or key legend; the shared footer swaps
to ↓ Tab · ↑ Shift+Tab · ✓ Enter · ✕ Esc while nav is focused. Library and installed
offline games remain accessible
while signed out; a long library scrolls without displacing the account row.
- Ctrl+N directly toggles navigation and page content, even if an action
previously had focus. It never steps through the footer. Tab is next and
Shift+Tab is previous within the focused list, action row, form, dialog, or
pause menu. They do not open or close navigation. Arrows still select
within the focused area; Enter activates. A
normal game row opens details; Continue Playing and the explicit Play action
launch the game.
- When open at 120 or more columns, navigation reserves 22 columns and pushes
the content. On smaller terminals it overlays without reflowing the content.
In both cases it stops above the main divider so page actions and keyboard
hints stay fully visible. Closed navigation reserves no width. Non-game
content stays centered and capped at 100 columns. If pushing would squeeze a
fixed-size game, navigation uses the overlay instead, even on a wide terminal.
- The supported baseline is 80×24. Lists and long text scroll. Page Up/Down
moves list selection with the viewport, so actions do not target a hidden row.
- During active gameplay, keys belong to the game. Esc/P pauses; Tab can then
- During active gameplay, keys belong to the game. Esc/P pauses; Ctrl+N can then
focus navigation. Leaving Play closes the guest and records an abandoned run.
- Pause retains Resume, Restart, pixel selection for the next start, and Leave
Play. Pixel changes do not stretch or mutate an existing guest framebuffer.
- Forms consume typed/pasted characters. Tab moves between fields/buttons;
left/right, Home/End, Backspace/Delete and Ctrl+U edit a field. Enter activates
a button rather than silently submitting from a text field.
- Ctrl+C quits from every state. Escape dismisses/backtracks outside gameplay.
- Product pages/states share one bottom-anchored footer that spans the full
terminal width. Its contents follow the focused context (navigation, page
actions, forms, gameplay). Keyboard hints use ↓ Tab, ↑ Shift+Tab, ✓ Enter,
and ✕ Esc. Marketplace/library game actions are keyed directly: ✕ Esc Back,
↻ R Refresh, ▶ P Play, + A Add, and ⌫ U Uninstall.
Pause choices and game hints are in this footer rather than over the artwork.
Footer rows are reserved before content is laid out; games keep their fixed
cell dimensions and show a too-small notice rather than being cropped.

## Library and package actions

Marketplace and Library have a Search row at the top of the list. Tab/Shift+Tab
or arrows move onto it to type; `/` jumps there. Enter, Tab, or down moves to
results without clearing. Esc is still Back. Ctrl+U clears the query. Queries
survive visiting details or another destination during this TUI session.

Marketplace uses server name/slug/description search (not owner), debounced by
300ms, and follows cursors through the full result set. An API/network failure
shows an error rather than a complete-looking partial list. The API accepts at
most 64 UTF-8 bytes after trimming. Library immediately filters its view using
the same fields when metadata is available, including installed local-only games.
Neither sidebar games nor shared membership are filtered. Continue Playing is
hidden while the query is nonblank; an empty filtered list says No matching games.

Library is an ID-keyed union of account membership and installed packages.
Installed versions are shown independently of the newest registry metadata.

Expand Down Expand Up @@ -70,6 +104,22 @@ revoke. Account changes/expiry clear cached private member/session views.

## Local development pairing

Choose the bottom Sign in item (or Sign in within Settings) to open a centered
dialog over the current screen. The dialog title is Sign in. Below it, the
pairing URL, a blank line, the pairing code, and Open browser / Cancel options
are centered. The URL is `/pair/ABCD-EFGH` so the pair page can look the device
up automatically; it remains an OSC-8 hyperlink in terminals that support
clicks. Approval in the browser is still explicit. Open browser is explicit,
and passwords/email codes are never entered in the TUI. Tab or arrows select
those options; Enter activates. Ctrl+N does nothing in the dialog. Esc
closes it without navigating away. Successful sign-in also closes
it. A paused game remains
paused and open underneath. Very small terminals show a resize notice and
accept only Esc/Ctrl+C until controls fit. Status sits above the divider; below
it the hints are ↓ Tab, ↑ Shift+Tab, ✓ Enter, and ✕ Esc.
Cancel stops waiting; if credential saving has begun, the shell reconciles
actual state because a completed save cannot be undone by canceling its result.

Use the actual API/app ports printed by `make dev` in the backend checkout:

```sh
Expand Down
52 changes: 52 additions & 0 deletions internal/registry/catalog_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package registry

import (
"context"
"encoding/json"
"fmt"
"net/http"
Expand All @@ -9,10 +10,58 @@ import (
"path/filepath"
"strings"
"testing"
"time"

"github.com/aviorstudio/termcade/sdk"
)

func TestSearchCatalogFullTraversal(t *testing.T) {
requests := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("q") != "blocks & é" || r.URL.Query().Get("abi") != "" {
t.Errorf("wrong search query: %s", r.URL.RawQuery)
}
requests++
page := CatalogPage{Games: []Game{{ID: "owner/duplicate"}, {ID: fmt.Sprintf("owner/game-%d", requests)}}}
if requests == 2 {
page.Games = nil
}
if requests < 23 {
page.Next = fmt.Sprint(requests)
}
json.NewEncoder(w).Encode(page)
}))
defer server.Close()
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
games, err := New(server.URL, "").WithContext(ctx).SearchCatalog(" blocks & é ")
if err != nil || requests != 23 || len(games) != 23 {
t.Fatalf("games=%d requests=%d err=%v", len(games), requests, err)
}
}

func TestSearchCatalogRejectsCursorCyclesAndCancellation(t *testing.T) {
requests := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests++
cursor := "A"
if requests%2 == 0 {
cursor = "B"
}
json.NewEncoder(w).Encode(CatalogPage{Games: []Game{{ID: "owner/partial"}}, Next: cursor})
}))
defer server.Close()
games, err := New(server.URL, "").SearchCatalog("x")
if err == nil || games != nil || requests != 3 {
t.Fatalf("cycle returned partial success: %v %v %d", games, err, requests)
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
if _, err := New(server.URL, "").WithContext(ctx).SearchCatalog("x"); err == nil {
t.Fatal("canceled search succeeded")
}
}

// The catalog is paged, so browsing the marketplace is several requests. The
// failure this guards against is quiet: a client that stops early shows a
// short marketplace and nothing anywhere says a game is missing.
Expand Down Expand Up @@ -179,6 +228,9 @@ func TestCatalogExampleDecodes(t *testing.T) {
if game.CreatedAt == "" || game.ReleasedAt == "" {
t.Errorf("timestamps did not decode: %+v", game)
}
if game.Likes != 0 {
t.Errorf("recorded likes = %d, want 0", game.Likes)
}
}

func TestGameExampleDecodes(t *testing.T) {
Expand Down
49 changes: 48 additions & 1 deletion internal/registry/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,8 @@ type Game struct {
// was published; the second is absent on a game that has none.
CreatedAt string `json:"created_at,omitempty"`
ReleasedAt string `json:"released_at,omitempty"`
Likes int `json:"likes"`
Liked bool `json:"liked,omitempty"`
}

// Resolved is which release to install and what it must hash to. The registry
Expand Down Expand Up @@ -303,7 +305,7 @@ type CatalogQuery struct {
Cursor string
// Limit is 1-200; zero lets the registry choose.
Limit int
// Search matches a game's name, id or description.
// Search matches a game's name, slug or description (not its owner).
Search string
// ABI restricts to games this arcade can run. Set by Games(); the registry
// treats zero as "do not filter".
Expand Down Expand Up @@ -361,6 +363,36 @@ func (c *Client) Games() ([]Game, error) {
// play. Availability belongs on each action, not in an invisible list filter.
func (c *Client) Catalog() ([]Game, error) { return c.catalog(CatalogQuery{}) }

// SearchCatalog walks the full public result set, including short pages. Unlike
// the legacy Games convenience method it never silently truncates at 20 pages.
// Callers should supply a cancellable/deadlined client context.
func (c *Client) SearchCatalog(search string) ([]Game, error) {
query := CatalogQuery{Search: strings.TrimSpace(search)}
var all []Game
seen := map[string]bool{}
ids := map[string]bool{}
for {
page, err := c.CatalogPage(query)
if err != nil {
return nil, err
}
for _, game := range page.Games {
if !ids[game.ID] {
all = append(all, game)
ids[game.ID] = true
}
}
if page.Next == "" {
return all, nil
}
if seen[page.Next] {
return nil, fmt.Errorf("marketplace returned a repeated page cursor")
}
seen[page.Next] = true
query.Cursor = page.Next
}
}

func (c *Client) catalog(query CatalogQuery) ([]Game, error) {
var all []Game
for range maxCatalogPages {
Expand Down Expand Up @@ -502,6 +534,21 @@ func (c *Client) LibraryRemove(author, slug string) error {
return c.do(http.MethodDelete, "/v1/library/"+author+"/"+slug, nil, nil)
}

type LikeState struct {
Likes int `json:"likes"`
Liked bool `json:"liked"`
}

func (c *Client) Like(author, slug string) (LikeState, error) {
var out LikeState
return out, c.do(http.MethodPut, "/v1/games/"+author+"/"+slug+"/like", nil, &out)
}

func (c *Client) Unlike(author, slug string) (LikeState, error) {
var out LikeState
return out, c.do(http.MethodDelete, "/v1/games/"+author+"/"+slug+"/like", nil, &out)
}

// Library lists the games on this account, newest addition first. It is the
// server's copy: what you have added anywhere, not what is installed here.
func (c *Client) Library() ([]Game, error) {
Expand Down
3 changes: 2 additions & 1 deletion internal/registry/contract/catalog.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@
"sha256": "0000000000000000000000000000000000000000000000000000000000000000",
"has_package": true,
"created_at": "2026-07-30T09:00:00Z",
"released_at": "2026-08-01T12:00:00Z"
"released_at": "2026-08-01T12:00:00Z",
"likes": 0
}
],
"next": "MQBzbHVnAGJyaWNrb3VnaABnLWJyaWNr"
Expand Down
3 changes: 2 additions & 1 deletion internal/registry/contract/game.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,6 @@
"sha256": "0000000000000000000000000000000000000000000000000000000000000000",
"has_package": true,
"created_at": "2026-07-30T09:00:00Z",
"released_at": "2026-08-01T12:00:00Z"
"released_at": "2026-08-01T12:00:00Z",
"likes": 0
}
1 change: 1 addition & 0 deletions internal/registry/contract/library.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"has_package": true,
"created_at": "2026-07-30T09:00:00Z",
"released_at": "2026-08-01T12:00:00Z",
"likes": 0,
"activity": {
"id": "aviorstudio/brickough",
"personal_best": 4242,
Expand Down
19 changes: 19 additions & 0 deletions internal/registry/device.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"net/http"
"net/url"
"regexp"
"strings"
"time"
Expand Down Expand Up @@ -59,6 +60,24 @@ type DevicePoll struct {
Interval int64 `json:"interval"`
}

// PairingURLWithCode appends the displayed user code as /pair/ABCD-EFGH so
// opening the link can look the device up. Invalid codes are not attached.
func PairingURLWithCode(base, code string) string {
code = strings.ToUpper(strings.TrimSpace(code))
if base == "" || !userCodeRE.MatchString(code) {
return base
}
parsed, err := url.Parse(base)
if err != nil || parsed.Host == "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" || (parsed.Scheme != "http" && parsed.Scheme != "https") {
return base
}
if strings.TrimRight(parsed.Path, "/") != "/pair" {
return base
}
parsed.Path = "/pair/" + code
return parsed.String()
}

func (c *Client) StartDevice(ctx context.Context, deviceName string) (DeviceRound, error) {
deviceName = safeText(deviceName, 200)
if deviceName == "" {
Expand Down
16 changes: 16 additions & 0 deletions internal/registry/device_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -258,3 +258,19 @@ func TestRevokedCredentialIsNotReturnedAsSession(t *testing.T) {
t.Fatalf("revocation error = %v, want ErrLoginRequired", err)
}
}

func TestPairingURLWithCodeAttachesOnlyAValidUserCode(t *testing.T) {
base := "https://app.termca.de/pair"
got := PairingURLWithCode(base, " abcd-efgh ")
if got != base+"/ABCD-EFGH" {
t.Fatalf("got %q", got)
}
for _, code := range []string{"", "not-a-code", "ABCD-EFGH/../x", "https://evil.example"} {
if PairingURLWithCode(base, code) != base {
t.Fatalf("attached invalid code %q", code)
}
}
if PairingURLWithCode("https://user:pass@app.termca.de/pair", "ABCD-EFGH") != "https://user:pass@app.termca.de/pair" {
t.Fatal("credentials in pairing URL were preserved into a complete link")
}
}
2 changes: 1 addition & 1 deletion internal/shell/menu.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import (
)

var (
logoStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#3fc4c9"))
logoStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#e6c945"))
selectedStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#e6c945"))
normalStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#f2f2f2"))
)
Expand Down
Loading