From 94000f074455261b2d904700b2600c37239c5d31 Mon Sep 17 00:00:00 2001 From: merefield Date: Tue, 20 Oct 2026 14:40:18 +0100 Subject: [PATCH 1/3] FEAT: cache slash command catalogues and navigation in memory --- README.md | 13 +- go.mod | 2 +- internal/codex/client.go | 12 +- internal/codex/daemon_status_unix.go | 2 + internal/codex/live_usage.go | 2 + internal/codex/session_command_cache.go | 277 ++++++++++++++++++ internal/codex/session_command_cache_test.go | 245 ++++++++++++++++ internal/codex/session_commands.go | 50 +++- internal/codex/session_commands_unix.go | 44 ++- internal/codex/session_commands_unix_test.go | 70 +++++ internal/ui/monitor_command_cache_test.go | 89 ++++++ internal/ui/monitor_commands.go | 21 +- internal/ui/monitor_commands_inline.go | 2 +- internal/ui/monitor_suggestions.go | 10 +- internal/web/control_commands.go | 9 +- internal/web/control_commands_test.go | 36 +++ .../{index-BWH3mrUB.js => index-D1LQiLkx.js} | 4 +- internal/web/dist/index.html | 2 +- web/src/SessionCommands.svelte | 9 +- web/tests/browser.spec.ts | 6 + 20 files changed, 877 insertions(+), 28 deletions(-) create mode 100644 internal/codex/session_command_cache.go create mode 100644 internal/codex/session_command_cache_test.go create mode 100644 internal/ui/monitor_command_cache_test.go rename internal/web/dist/assets/{index-BWH3mrUB.js => index-D1LQiLkx.js} (95%) diff --git a/README.md b/README.md index f1751d4..99958a9 100644 --- a/README.md +++ b/README.md @@ -1667,8 +1667,17 @@ the popup shrinks as candidates drop out without moving the composer. Longer lists scroll within the available space (arrows or mouse wheel in the terminal). Up/Down selects, Tab completes the name, Enter opens its options and Escape dismisses suggestions while retaining the draft and typing focus. Suggestions -are fetched asynchronously and filtered locally. They also work in a selected -wide terminal session row when space permits. In terminal full detail, pressing +are filtered locally. Codexometer warms command catalogues in the background as +connected sessions are discovered, retaining the submenus already fetched during +discovery and any deeper levels visited afterwards in a +bounded, in-memory tree shared by the terminal and web backends. Cached levels +can be reused for up to two minutes; connection, directory, name and model-setting +changes invalidate the affected metadata. The terminal opens warm menus immediately. +Local display commands remain available while initial discovery is running. +Explicit **Refresh** bypasses the cache, and applying a setting always validates +fresh server state. Nothing is written to disk; restarting clears the cache. +Suggestions also work in a selected wide terminal session row when space permits. +In terminal full detail, pressing `/` also focuses the composer and starts this expansion when a composer is available; read-only sessions retain a command picker for local display settings. Submenus stay in that same expanding list above the command line: type to filter diff --git a/go.mod b/go.mod index 59def12..855572a 100644 --- a/go.mod +++ b/go.mod @@ -12,6 +12,7 @@ require ( github.com/charmbracelet/x/term v0.2.2 github.com/gorilla/websocket v1.5.3 go.starlark.net v0.0.0-20260930220527-d7438c5a85ac + golang.org/x/sync v0.23.0 golang.org/x/sys v0.48.0 golang.org/x/text v0.42.0 ) @@ -27,5 +28,4 @@ require ( github.com/muesli/cancelreader v0.2.2 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect - golang.org/x/sync v0.23.0 // indirect ) diff --git a/internal/codex/client.go b/internal/codex/client.go index d22e5cd..4bc94cb 100644 --- a/internal/codex/client.go +++ b/internal/codex/client.go @@ -39,7 +39,11 @@ func (c Client) FetchTokenUsage(ctx context.Context) (LiveUsageSnapshot, error) if c.LiveUsage == nil { return LiveUsageSnapshot{}, errors.New("local Codex session telemetry is not configured") } - return c.LiveUsage.FetchTokenUsage(ctx) + usage, err := c.LiveUsage.FetchTokenUsage(ctx) + if err == nil { + c.warmSessionCommands(usage) + } + return usage, err } // FetchTokenUsageFresh forces complete local rollout discovery. @@ -47,7 +51,11 @@ func (c Client) FetchTokenUsageFresh(ctx context.Context) (LiveUsageSnapshot, er if c.LiveUsage == nil { return LiveUsageSnapshot{}, errors.New("local Codex session telemetry is not configured") } - return c.LiveUsage.FetchTokenUsageFresh(ctx) + usage, err := c.LiveUsage.FetchTokenUsageFresh(ctx) + if err == nil { + c.warmSessionCommands(usage) + } + return usage, err } type rpcError struct { diff --git a/internal/codex/daemon_status_unix.go b/internal/codex/daemon_status_unix.go index c66a7e1..27fb151 100644 --- a/internal/codex/daemon_status_unix.go +++ b/internal/codex/daemon_status_unix.go @@ -22,6 +22,7 @@ const ( ) type daemonStatusProvider struct { + commandGeneration uint64 queueRevision uint64 queueCapabilities map[string]queueCapability contexts map[string]*daemonContextState @@ -251,6 +252,7 @@ func (p *daemonStatusProvider) ensureConnected(ctx context.Context) error { return nil } p.connection = connection + p.commandGeneration++ p.pending = make(map[int64]chan daemonEnvelope) p.subscribed = make(map[string]struct{}) p.reroutedTurns = make(map[daemonTurnKey]string) diff --git a/internal/codex/live_usage.go b/internal/codex/live_usage.go index 68def7e..b58a9c6 100644 --- a/internal/codex/live_usage.go +++ b/internal/codex/live_usage.go @@ -133,6 +133,8 @@ type LiveUsageReader struct { SessionsRoot string WriterLocksRoot string statusProvider sessionStatusProvider + commandMu sync.Mutex + commandMenus *sessionCommandCache mu sync.Mutex initialized bool diff --git a/internal/codex/session_command_cache.go b/internal/codex/session_command_cache.go new file mode 100644 index 0000000..479de5f --- /dev/null +++ b/internal/codex/session_command_cache.go @@ -0,0 +1,277 @@ +package codex + +import ( + "context" + "encoding/json" + "strings" + "sync" + "time" + + "golang.org/x/sync/singleflight" +) + +const ( + commandMenuLifetime = 2 * time.Minute + commandMenuLimit = 128 + commandMenuMaxBytes = 256 * 1024 +) + +type commandMenuKey struct{ session, path string } +type commandMenuBranch struct { + menu SessionCommandMenu + scope string +} +type commandMenuEntry struct { + menu *SessionCommandMenu + scope string + at, used time.Time +} +type commandMenuObservation struct { + name, directory string + settings SessionModelSettings +} + +// A path-indexed tree of display metadata. Actions always go to the original +// client, which validates a freshly read revision before sending any write. +type sessionCommandCache struct { + mu sync.Mutex + source SessionCommandsClient + entries map[commandMenuKey]commandMenuEntry + observations map[string]commandMenuObservation + generation uint64 + reads singleflight.Group + workers chan struct{} + warmers chan struct{} + now func() time.Time +} + +func newSessionCommandCache(source SessionCommandsClient) *sessionCommandCache { + return &sessionCommandCache{source: source, entries: make(map[commandMenuKey]commandMenuEntry), + workers: make(chan struct{}, 4), warmers: make(chan struct{}, 2), now: time.Now} +} + +func commandMenuPath(path string) string { + return strings.TrimPrefix(strings.TrimSpace(path), "/") +} + +func cacheableCommandPath(path string) bool { + // Typed names and paths are short-lived reviews, not catalogue branches. + return ValidSessionCommandPath(path) && !strings.HasPrefix(path, "/") && !strings.HasPrefix(path, "rename/") && + !strings.HasPrefix(path, "rename ") && !strings.HasPrefix(path, "cd/") && !strings.HasPrefix(path, "cd ") +} + +func (c *sessionCommandCache) scope(id, path string) string { + if source, ok := c.source.(interface{ sessionCommandScope(string, string) string }); ok { + return source.sessionCommandScope(id, path) + } + return "local" +} + +func browsingCommandMenu(menu SessionCommandMenu) SessionCommandMenu { + menu.Choices = append([]SessionCommandChoice(nil), menu.Choices...) + for i := range menu.Choices { + menu.Choices[i].method, menu.Choices[i].params = "", nil + } + return menu +} + +func (c *sessionCommandCache) peek(id, path string) (SessionCommandMenu, bool) { + path = commandMenuPath(path) + scope := c.scope(id, path) + if scope == "" || !cacheableCommandPath(path) { + return SessionCommandMenu{}, false + } + c.mu.Lock() + defer c.mu.Unlock() + key, now := commandMenuKey{id, path}, c.now() + e, ok := c.entries[key] + if !ok || e.menu == nil || e.scope != scope || now.Sub(e.at) >= commandMenuLifetime { + return SessionCommandMenu{}, false + } + e.used = now + c.entries[key] = e + return browsingCommandMenu(*e.menu), true +} + +func (c *sessionCommandCache) invalidateLocked(id string) { + c.generation++ // Also prevents reads started before invalidation repopulating it. + for key := range c.entries { + if id == "" || key.session == id { + delete(c.entries, key) + } + } +} + +func (c *sessionCommandCache) invalidate(id string) { + c.mu.Lock() + defer c.mu.Unlock() + c.invalidateLocked(id) +} + +func (c *sessionCommandCache) rememberLocked(key commandMenuKey, entry commandMenuEntry) { + if _, exists := c.entries[key]; !exists && len(c.entries) >= commandMenuLimit { + var oldest commandMenuKey + var at time.Time + for k, e := range c.entries { + if at.IsZero() || e.used.Before(at) { + oldest, at = k, e.used + } + } + delete(c.entries, oldest) + } + c.entries[key] = entry +} + +func (c *sessionCommandCache) load(ctx context.Context, id, path string, fresh bool) (SessionCommandMenu, error) { + if err := ctx.Err(); err != nil { + return SessionCommandMenu{}, err + } + originalPath := path + path = commandMenuPath(path) + if fresh { + c.invalidate(id) + } + if !fresh { + if menu, ok := c.peek(id, originalPath); ok { + return menu, nil + } + } + scope := c.scope(id, path) + if scope == "" || !cacheableCommandPath(path) { + return c.source.SessionCommands(ctx, id, originalPath) + } + c.mu.Lock() + generation := c.generation + c.mu.Unlock() + key := commandMenuKey{id, path} + flight, _ := json.Marshal([]any{id, path, scope, generation}) + result := c.reads.DoChan(string(flight), func() (any, error) { + if !fresh { + if menu, hit := c.peek(id, path); hit { + return menu, nil + } + } + // One caller cancelling must not cancel a shared read for other users. + readCtx, cancel := context.WithTimeout(context.Background(), 12*time.Second) + defer cancel() + select { + case c.workers <- struct{}{}: + defer func() { <-c.workers }() + case <-readCtx.Done(): + return nil, readCtx.Err() + } + var menu SessionCommandMenu + var branches map[string]commandMenuBranch + var err error + if source, ok := c.source.(interface { + sessionCommandTree(context.Context, string, string) (SessionCommandMenu, map[string]commandMenuBranch, error) + }); ok { + menu, branches, err = source.sessionCommandTree(readCtx, id, path) + } else { + menu, err = c.source.SessionCommands(readCtx, id, path) + } + if err == nil { + err = readCtx.Err() // Do not retain a partial catalogue after a deadline. + } + if err != nil { + return nil, err + } + menu = browsingCommandMenu(menu) + if c.scope(id, path) != scope { + return nil, ErrSessionCommand + } + for childPath, branch := range branches { + if !cacheableCommandPath(childPath) || c.scope(id, childPath) != branch.scope { + delete(branches, childPath) + } + } + c.mu.Lock() + defer c.mu.Unlock() + if c.generation != generation { + return nil, ErrSessionCommand + } + for childPath, branch := range branches { + c.storeMenuLocked(commandMenuKey{id, childPath}, branch.menu, branch.scope) + } + c.storeMenuLocked(key, menu, scope) + return menu, nil + }) + select { + case <-ctx.Done(): + return SessionCommandMenu{}, ctx.Err() + case r := <-result: + if r.Err != nil { + return SessionCommandMenu{}, r.Err + } + return browsingCommandMenu(r.Val.(SessionCommandMenu)), nil + } +} + +func (c *sessionCommandCache) storeMenuLocked(key commandMenuKey, menu SessionCommandMenu, scope string) { + menu = browsingCommandMenu(menu) + encoded, _ := json.Marshal(menu) + if len(encoded) <= commandMenuMaxBytes { + now := c.now() + c.rememberLocked(key, commandMenuEntry{menu: &menu, scope: scope, at: now, used: now}) + } +} + +// Observe normal telemetry; it never waits for catalogue I/O. Only two root +// warmers run at once, with backoff for unavailable catalogues. Deeper levels +// join the same cache when first visited, regardless of path depth. +func (c *sessionCommandCache) observe(usage LiveUsageSnapshot) { + observations := make(map[string]commandMenuObservation, len(usage.Sessions)) + for _, s := range usage.Sessions { + observations[s.ID] = commandMenuObservation{s.Name, s.WorkingDirectory, s.ModelSettings} + } + c.mu.Lock() + for id, previous := range c.observations { + if current, ok := observations[id]; !ok || current != previous { + c.invalidateLocked(id) + } + } + c.observations = observations + if !usage.CodexUp { + c.invalidateLocked("") + } + c.mu.Unlock() + if !usage.CodexUp { + return + } + for _, s := range usage.Sessions { + if !s.Active || s.ID == "" { + continue + } + c.warm(s.ID) + } +} + +func (c *sessionCommandCache) warm(id string) { + if _, ok := c.peek(id, ""); ok { + return + } + scope := c.scope(id, "") + if scope == "" { + return + } + c.mu.Lock() + key, now := commandMenuKey{id, ""}, c.now() + if e, ok := c.entries[key]; ok && e.scope == scope && now.Sub(e.at) < 15*time.Second { + c.mu.Unlock() + return + } + select { + case c.warmers <- struct{}{}: + default: + c.mu.Unlock() + return + } + c.rememberLocked(key, commandMenuEntry{scope: scope, at: now, used: now}) + c.mu.Unlock() + go func() { + defer func() { <-c.warmers }() + ctx, cancel := context.WithTimeout(context.Background(), 12*time.Second) + defer cancel() + _, _ = c.load(ctx, id, "", false) + }() +} diff --git a/internal/codex/session_command_cache_test.go b/internal/codex/session_command_cache_test.go new file mode 100644 index 0000000..6a31244 --- /dev/null +++ b/internal/codex/session_command_cache_test.go @@ -0,0 +1,245 @@ +package codex + +import ( + "context" + "errors" + "fmt" + "strings" + "sync" + "testing" + "time" +) + +type menuCacheSource struct { + mu sync.Mutex + reads map[string]int + epoch string + fail bool + help string + started chan string + release chan struct{} +} + +func (f *menuCacheSource) sessionCommandScope(string, string) string { + f.mu.Lock() + defer f.mu.Unlock() + return f.epoch +} + +func (f *menuCacheSource) SessionCommands(ctx context.Context, id, path string) (SessionCommandMenu, error) { + f.mu.Lock() + if f.reads == nil { + f.reads = map[string]int{} + } + f.reads[id+":"+path]++ + fail, help, epoch := f.fail, f.help, f.epoch + f.mu.Unlock() + if f.started != nil { + f.started <- id + ":" + path + } + if f.release != nil { + select { + case <-f.release: + case <-ctx.Done(): + return SessionCommandMenu{}, ctx.Err() + } + } + if fail { + return SessionCommandMenu{}, ErrSessionCommand + } + return SessionCommandMenu{Path: path, Revision: epoch, Choices: []SessionCommandChoice{{ + ID: "choice", Label: "Option", Help: help, Action: true, + method: "thread/settings/update", params: map[string]any{"threadId": id}, + }}}, nil +} + +func (f *menuCacheSource) ExecuteSessionCommand(context.Context, string, string, string, string) error { + return errors.New("cache must never execute actions") +} + +func (f *menuCacheSource) count(id, path string) int { + f.mu.Lock() + defer f.mu.Unlock() + return f.reads[id+":"+path] +} + +func loadMenu(t *testing.T, c *sessionCommandCache, id, path string) SessionCommandMenu { + t.Helper() + menu, err := c.load(context.Background(), id, path, false) + if err != nil { + t.Fatal(err) + } + return menu +} + +func TestCommandCacheRetainsTreeAndIsolatesSessionsAndCopies(t *testing.T) { + f := &menuCacheSource{epoch: "connection-one"} + c := newSessionCommandCache(f) + for _, path := range []string{"", "model", "model/small", "nested/one/two/three"} { + menu := loadMenu(t, c, "one", path) + menu.Choices[0].Label = "Mutated by caller" + again := loadMenu(t, c, "one", path) + if again.Choices[0].Label != "Option" || again.Choices[0].method != "" || again.Choices[0].params != nil || !again.Choices[0].Action || f.count("one", path) != 1 { + t.Fatal("cache lost metadata isolation or retained executable parameters") + } + loadMenu(t, c, "two", path) + if f.count("two", path) != 1 { + t.Fatal("menus crossed session boundaries") + } + } + loadMenu(t, c, "one", "//model") + if f.count("one", "//model") != 1 { + t.Fatal("cache changed the source semantics of a double-slash path") + } +} + +func TestCommandCacheExpiryRefreshAndConnectionScope(t *testing.T) { + f := &menuCacheSource{epoch: "first"} + c := newSessionCommandCache(f) + now := time.Unix(100, 0) + c.now = func() time.Time { return now } + loadMenu(t, c, "one", "model") + now = now.Add(commandMenuLifetime) + loadMenu(t, c, "one", "model") + if f.count("one", "model") != 2 { + t.Fatal("expired menu was reused") + } + if _, err := c.load(context.Background(), "one", "model", true); err != nil || f.count("one", "model") != 3 { + t.Fatal("explicit refresh did not bypass cache", err) + } + f.mu.Lock() + f.epoch = "reconnected" + f.mu.Unlock() + if _, ok := c.peek("one", "model"); ok { + t.Fatal("old connection menu remained available") + } + if got := loadMenu(t, c, "one", "model"); got.Revision != "reconnected" { + t.Fatal("connection scope was not refreshed") + } + f.mu.Lock() + f.epoch = "" + f.mu.Unlock() + if _, ok := c.peek("one", "model"); ok { + t.Fatal("disconnected cache remained available") + } +} + +func TestCommandCacheDeduplicatesReadsAndCallerCancellation(t *testing.T) { + f := &menuCacheSource{epoch: "one", started: make(chan string, 20), release: make(chan struct{})} + c := newSessionCommandCache(f) + ctx, cancel := context.WithCancel(context.Background()) + first := make(chan error, 1) + go func() { _, err := c.load(ctx, "one", "model", false); first <- err }() + <-f.started + var wg sync.WaitGroup + for range 12 { + wg.Go(func() { + if _, err := c.load(context.Background(), "one", "model", false); err != nil { + t.Error(err) + } + }) + } + cancel() + if err := <-first; !errors.Is(err, context.Canceled) { + t.Fatal("cancelled caller did not return promptly", err) + } + close(f.release) + wg.Wait() + if f.count("one", "model") != 1 { + t.Fatal("concurrent browsing made duplicate catalogue reads") + } +} + +func TestCommandCacheRejectsInvalidatedInflightResult(t *testing.T) { + for _, scopeChange := range []bool{false, true} { + t.Run(fmt.Sprint(scopeChange), func(t *testing.T) { + f := &menuCacheSource{epoch: "old", started: make(chan string, 1), release: make(chan struct{})} + c := newSessionCommandCache(f) + done := make(chan error, 1) + go func() { _, err := c.load(context.Background(), "one", "model", false); done <- err }() + <-f.started + if scopeChange { + f.mu.Lock() + f.epoch = "new" + f.mu.Unlock() + } else { + c.invalidate("one") + } + close(f.release) + if err := <-done; !errors.Is(err, ErrSessionCommand) { + t.Fatal("invalidated in-flight result was returned", err) + } + if _, ok := c.peek("one", "model"); ok { + t.Fatal("invalidated in-flight result repopulated cache") + } + }) + } +} + +func TestCommandCacheBoundsAndExcludesTypedArgumentsAndFailures(t *testing.T) { + f := &menuCacheSource{epoch: "one"} + c := newSessionCommandCache(f) + for i := range commandMenuLimit + 4 { + loadMenu(t, c, "one", fmt.Sprintf("branch/%d", i)) + } + if len(c.entries) != commandMenuLimit { + t.Fatal("unbounded menu retention") + } + for _, path := range []string{"rename/secret-name", "cd/%2Fprivate", "rename secret", "cd /private", "/rename/name", "//rename/name", "//cd/private"} { + loadMenu(t, c, "one", path) + loadMenu(t, c, "one", path) + if f.count("one", path) != 2 { + t.Fatal("typed argument was retained", path) + } + } + f.fail = true + if _, err := c.load(context.Background(), "one", "failed", false); err == nil { + t.Fatal("missing source failure") + } + f.fail = false + loadMenu(t, c, "one", "failed") + if f.count("one", "failed") != 2 { + t.Fatal("failure prevented recovery") + } + f.help = strings.Repeat("x", commandMenuMaxBytes) + loadMenu(t, c, "one", "oversized") + if _, ok := c.peek("one", "oversized"); ok { + t.Fatal("oversized menu was retained") + } +} + +func TestCommandCacheWarmsWithoutBlockingAndInvalidatesObservedSettings(t *testing.T) { + f := &menuCacheSource{epoch: "one", started: make(chan string, 20), release: make(chan struct{})} + c := newSessionCommandCache(f) + usage := LiveUsageSnapshot{CodexUp: true, Sessions: []LiveUsageSession{{ID: "one", Active: true, Name: "A", WorkingDirectory: "/work"}}} + c.observe(usage) // Must return while the metadata request is blocked. + <-f.started + close(f.release) + loadMenu(t, c, "one", "") // Joins the startup warm, rather than repeating it. + if f.count("one", "") != 1 { + t.Fatal("first slash repeated the warmed catalogue fetch") + } + loadMenu(t, c, "one", "model/small") + usage.Sessions[0].Working = true + c.observe(usage) + if _, ok := c.peek("one", "model/small"); !ok { + t.Fatal("unrelated snapshot churn cleared the tree") + } + usage.Sessions[0].Name = "Renamed" + usage.Sessions[0].Active = false // Check invalidation without scheduling another warm. + c.observe(usage) + if _, ok := c.peek("one", "model/small"); ok { + t.Fatal("renamed session retained old metadata") + } + loadMenu(t, c, "one", "model") + usage.Sessions[0].ModelSettings.Model = "changed-model" + c.observe(usage) + if _, ok := c.peek("one", "model"); ok { + t.Fatal("changed model retained old metadata") + } + loadMenu(t, c, "one", "model") + c.observe(LiveUsageSnapshot{}) + if _, ok := c.peek("one", "model"); ok { + t.Fatal("removed or disconnected session retained old metadata") + } +} diff --git a/internal/codex/session_commands.go b/internal/codex/session_commands.go index 1856c3d..7cf5650 100644 --- a/internal/codex/session_commands.go +++ b/internal/codex/session_commands.go @@ -106,6 +106,13 @@ type SessionCommandsClient interface { ExecuteSessionCommand(context.Context, string, string, string, string) error } +// Optional browsing fast path. Fresh reads and writes keep their existing +// server-side revision checks; cached menus contain display metadata only. +type CachedSessionCommandsClient interface { + CachedSessionCommands(string, string) (SessionCommandMenu, bool) + SessionCommandsFresh(context.Context, string, string) (SessionCommandMenu, error) +} + var ErrSessionCommand = errors.New("command unavailable or session/options changed; reopen the command menu") func IsSessionCommand(text string) bool { @@ -128,13 +135,52 @@ func (c Client) commandClient() SessionCommandsClient { return nil } func (c Client) SessionCommands(ctx context.Context, id, path string) (SessionCommandMenu, error) { - if p := c.commandClient(); p != nil { - return p.SessionCommands(ctx, id, path) + if cache := c.sessionCommandMenus(); cache != nil { + return cache.load(ctx, id, path, false) } return SessionCommandMenu{}, ErrSessionCommand } + +func (c Client) sessionCommandMenus() *sessionCommandCache { + source := c.commandClient() + if source == nil { + return nil + } + r := c.LiveUsage + r.commandMu.Lock() + defer r.commandMu.Unlock() + if r.commandMenus == nil { + r.commandMenus = newSessionCommandCache(source) + } + return r.commandMenus +} + +func (c Client) CachedSessionCommands(id, path string) (SessionCommandMenu, bool) { + if cache := c.sessionCommandMenus(); cache != nil { + return cache.peek(id, path) + } + return SessionCommandMenu{}, false +} + +func (c Client) SessionCommandsFresh(ctx context.Context, id, path string) (SessionCommandMenu, error) { + if cache := c.sessionCommandMenus(); cache != nil { + return cache.load(ctx, id, path, true) + } + return SessionCommandMenu{}, ErrSessionCommand +} + +func (c Client) warmSessionCommands(usage LiveUsageSnapshot) { + if cache := c.sessionCommandMenus(); cache != nil { + cache.observe(usage) + } +} + func (c Client) ExecuteSessionCommand(ctx context.Context, id, path, revision, choice string) error { if p := c.commandClient(); p != nil { + if cache := c.sessionCommandMenus(); cache != nil { + cache.invalidate(id) + defer cache.invalidate(id) + } return p.ExecuteSessionCommand(ctx, id, path, revision, choice) } return ErrSessionCommand diff --git a/internal/codex/session_commands_unix.go b/internal/codex/session_commands_unix.go index 775fe33..62558be 100644 --- a/internal/codex/session_commands_unix.go +++ b/internal/codex/session_commands_unix.go @@ -29,6 +29,19 @@ type commandThread struct { } } +func (p *daemonStatusProvider) sessionCommandScope(id, path string) string { + p.mu.Lock() + defer p.mu.Unlock() + if p.connection == nil { + return "" + } + status := p.statuses[id] + if path == "" || path == "help" { + status = sessionRuntimeUnknown // Root names/help survive ordinary turn transitions. + } + return commandDigest([]any{p.commandGeneration, status, p.settingsVersions[id], p.threadDirectories[id], p.threadSettings[id]}) +} + func commandDigest(value any) string { data, _ := json.Marshal(value) return fmt.Sprintf("%x", sha256.Sum256(data)) @@ -107,6 +120,16 @@ func (p *daemonStatusProvider) commandModels(ctx context.Context) ([]commandMode } func (p *daemonStatusProvider) SessionCommands(ctx context.Context, id, path string) (SessionCommandMenu, error) { + return p.sessionCommands(ctx, id, path, nil) +} + +func (p *daemonStatusProvider) sessionCommandTree(ctx context.Context, id, path string) (SessionCommandMenu, map[string]commandMenuBranch, error) { + branches := make(map[string]commandMenuBranch) + menu, err := p.sessionCommands(ctx, id, path, func(path string, branch commandMenuBranch) { branches[path] = branch }) + return menu, branches, err +} + +func (p *daemonStatusProvider) sessionCommands(ctx context.Context, id, path string, collect func(string, commandMenuBranch)) (SessionCommandMenu, error) { if !ValidSessionCommandPath(path) { return SessionCommandMenu{}, ErrSessionCommand } @@ -124,16 +147,20 @@ func (p *daemonStatusProvider) SessionCommands(ctx context.Context, id, path str if err != nil { return SessionCommandMenu{}, err } - menu, err := p.commandMenu(ctx, thread, path) + menu, err := p.commandMenu(ctx, thread, path, collect) if err != nil { return menu, err } + return p.finalizeCommandMenu(thread, path, menu), nil +} + +func (p *daemonStatusProvider) finalizeCommandMenu(thread commandThread, path string, menu SessionCommandMenu) SessionCommandMenu { menu.Path = path p.mu.Lock() connection := fmt.Sprintf("%p", p.connection) p.mu.Unlock() menu.Revision = commandDigest([]any{connection, thread.ID, thread.Cwd, thread.Status.Type, thread.Name, menu.Revision}) - return normalizeCommandMenu(menu), nil + return normalizeCommandMenu(menu) } func normalizeCommandMenu(menu SessionCommandMenu) SessionCommandMenu { @@ -162,7 +189,7 @@ func normalizeCommandMenu(menu SessionCommandMenu) SessionCommandMenu { return menu } -func (p *daemonStatusProvider) commandMenu(ctx context.Context, t commandThread, path string) (SessionCommandMenu, error) { +func (p *daemonStatusProvider) commandMenu(ctx context.Context, t commandThread, path string, collect func(string, commandMenuBranch)) (SessionCommandMenu, error) { m := SessionCommandMenu{Title: "/" + path, Help: "Live options from the shared Codex app-server.", Choices: []SessionCommandChoice{}} parts := strings.Split(path, "/") // Directory-scoped inventories must follow running settings after /cd, @@ -182,16 +209,21 @@ func (p *daemonStatusProvider) commandMenu(ctx context.Context, t commandThread, if path == "" || path == "help" { families := []string{"rename", "cd", "cwd", "pwd", "model", "plan", "permissions", "skills", "apps", "mcp", "hooks", "experimental"} menus := make([]SessionCommandMenu, len(families)) + scopes := make([]string, len(families)) errs := make([]error, len(families)) var wg sync.WaitGroup for i, family := range families { + scopes[i] = p.sessionCommandScope(t.ID, family) wg.Add(1) - go func() { defer wg.Done(); menus[i], errs[i] = p.commandMenu(ctx, t, family) }() + go func() { defer wg.Done(); menus[i], errs[i] = p.commandMenu(ctx, t, family, nil) }() } wg.Wait() for i, family := range families { if errs[i] == nil && (len(menus[i].Choices) > 0 || menus[i].Input) { m.Choices = append(m.Choices, SessionCommandChoice{Label: "/" + family, Help: menus[i].Help, Next: family}) + if collect != nil { + collect(family, commandMenuBranch{p.finalizeCommandMenu(t, family, menus[i]), scopes[i]}) + } } } if models, err := p.commandModels(ctx); err == nil { @@ -458,13 +490,13 @@ func (p *daemonStatusProvider) commandCatalogue(ctx context.Context, t commandTh m := SessionCommandMenu{Title: "/" + path, Help: "Browse-only catalogue from Codex. Select an entry to read its supplied help; use Codex to invoke or configure it.", Choices: []SessionCommandChoice{}} method := map[string]string{"skills": "skills/list", "apps": "app/list", "mcp": "mcpServerStatus/list", "hooks": "hooks/list", "experimental": "experimentalFeature/list"}[path] if method == "" { // Catalog tier aliases such as /fast, never assumed IDs. - root, err := p.commandMenu(ctx, t, "") + root, err := p.commandMenu(ctx, t, "", nil) if err != nil { return m, err } for _, o := range root.Choices { if o.Label == "/"+path { - return p.commandMenu(ctx, t, o.Next) + return p.commandMenu(ctx, t, o.Next, nil) } } return m, ErrSessionCommand diff --git a/internal/codex/session_commands_unix_test.go b/internal/codex/session_commands_unix_test.go index 30c8aff..a262e11 100644 --- a/internal/codex/session_commands_unix_test.go +++ b/internal/codex/session_commands_unix_test.go @@ -54,6 +54,76 @@ func TestSessionCommandsModelAndTier(t *testing.T) { } } +func TestCachedCommandClientRevalidatesWritesAndSettingsScope(t *testing.T) { + p, f := newQuotaDaemon(t) + ctx := context.Background() + if _, ok := p.Fetch(ctx, []string{"one"}); !ok { + t.Fatal("daemon unavailable") + } + c := Client{LiveUsage: &LiveUsageReader{statusProvider: p}} + menu, err := c.SessionCommands(ctx, "one", "model/small") + if err != nil || len(menu.Choices) != 1 || !menu.Choices[0].Action { + t.Fatal("missing model option", err) + } + if _, ok := c.CachedSessionCommands("one", "model/small"); !ok { + t.Fatal("menu did not enter the shared cache") + } + f.mu.Lock() + f.commandStatus = "active" // The cached menu still describes idle settings. + f.mu.Unlock() + if err := c.ExecuteSessionCommand(ctx, "one", menu.Path, menu.Revision, menu.Choices[0].ID); err == nil { + t.Fatal("write trusted cached settings after a turn started") + } + f.mu.Lock() + writes := len(f.writes) + f.commandStatus = "idle" + f.mu.Unlock() + if writes != 0 { + t.Fatal("stale cached command mutated the session") + } + if _, ok := c.CachedSessionCommands("one", "model/small"); ok { + t.Fatal("attempted write retained its old catalogue") + } + if _, err := c.SessionCommands(ctx, "one", "model/small"); err != nil { + t.Fatal(err) + } + p.mu.Lock() + p.settingsVersions["one"]++ + p.mu.Unlock() + if _, ok := c.CachedSessionCommands("one", "model/small"); ok { + t.Fatal("settings change did not invalidate cached options immediately") + } +} + +func TestCommandDiscoveryRetainsFetchedSubmenus(t *testing.T) { + p, _ := newQuotaDaemon(t) + ctx := context.Background() + if _, ok := p.Fetch(ctx, []string{"one"}); !ok { + t.Fatal("daemon unavailable") + } + c := Client{LiveUsage: &LiveUsageReader{statusProvider: p}} + c.warmSessionCommands(LiveUsageSnapshot{CodexUp: true, Sessions: []LiveUsageSession{{ID: "one", Active: true}}}) + if _, err := c.SessionCommands(ctx, "one", ""); err != nil { + t.Fatal(err) + } + for _, path := range []string{"model", "rename", "cd"} { + menu, ok := c.CachedSessionCommands("one", path) + if !ok || menu.Path != path || menu.Revision == "" || path == "model" && len(menu.Choices) != 1 { + t.Fatalf("discovery discarded the fetched %s submenu", path) + } + } + // A running turn invalidates actionable leaves but preserves root names/help. + p.mu.Lock() + p.statuses["one"] = sessionRuntimeWorking + p.mu.Unlock() + if _, ok := c.CachedSessionCommands("one", ""); !ok { + t.Fatal("turn transition discarded stable root suggestions") + } + if _, ok := c.CachedSessionCommands("one", "model"); ok { + t.Fatal("turn transition retained an idle submenu") + } +} + func TestSessionCommandCataloguesAndSafeHelp(t *testing.T) { p, f := newQuotaDaemon(t) f.catalogues = map[string]map[string]any{ diff --git a/internal/ui/monitor_command_cache_test.go b/internal/ui/monitor_command_cache_test.go new file mode 100644 index 0000000..c3bab65 --- /dev/null +++ b/internal/ui/monitor_command_cache_test.go @@ -0,0 +1,89 @@ +package ui + +import ( + "context" + "testing" + + tea "charm.land/bubbletea/v2" + "github.com/merefield/codexometer/internal/codex" +) + +type cachedCommandTestClient struct { + *suggestionClient + menus map[string]codex.SessionCommandMenu + fresh int +} + +func (f *cachedCommandTestClient) CachedSessionCommands(id, path string) (codex.SessionCommandMenu, bool) { + if id != "root-one" { + return codex.SessionCommandMenu{}, false + } + menu, ok := f.menus[path] + return menu, ok +} + +func (f *cachedCommandTestClient) SessionCommandsFresh(_ context.Context, _, path string) (codex.SessionCommandMenu, error) { + f.fresh++ + menu := f.menus[path] + menu.Revision = "fresh" + return menu, nil +} + +func TestMonitorCommandCacheOpensAndRetracesTreeImmediately(t *testing.T) { + m, prompt := promptTestModel() + f := &cachedCommandTestClient{suggestionClient: &suggestionClient{commandTestClient: &commandTestClient{promptTestClient: prompt}}, menus: map[string]codex.SessionCommandMenu{ + "": {Path: "", Choices: []codex.SessionCommandChoice{{ID: "model", Label: "/model", Next: "model"}}}, + "model": {Path: "model", Choices: []codex.SessionCommandChoice{{ID: "small", Label: "Small", Next: "model/small"}}}, + "model/small": {Path: "model/small", Choices: []codex.SessionCommandChoice{{ID: "medium", Label: "Medium", Action: true}}}, + }} + m.fetcher = f + m.focusMonitorPrompt() + m.monitorPrompt.input.SetValue("/m") + if cmd := m.syncMonitorSuggestions(); cmd != nil || m.monitorSuggestions.busy || len(m.suggestionChoices()) != 1 { + t.Fatal("warm root did not appear synchronously") + } + for _, path := range []string{"model", "model/small"} { + next, cmd := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + m = next.(Model) + if cmd != nil || m.monitorCommands.busy || m.monitorCommands.menu.Path != path || !m.monitorPrompt.input.Focused() { + t.Fatalf("cached level %q waited for a fetch or lost composer focus", path) + } + } + next, cmd := m.Update(tea.KeyPressMsg{Code: tea.KeyLeft, Mod: tea.ModAlt}) + m = next.(Model) + if cmd != nil || m.monitorCommands.menu.Path != "model" || m.monitorPrompt.input.Value() != "/model " { + t.Fatal("Back did not restore the cached level and draft immediately") + } + if f.reads != 0 || f.calls != 0 { + t.Fatal("cached browsing performed an RPC or a write") + } + next, cmd = m.Update(tea.KeyPressMsg{Code: 'r', Mod: tea.ModCtrl}) + m = next.(Model) + if cmd == nil || !m.monitorCommands.busy { + t.Fatal("explicit refresh reused cached data") + } + next, _ = m.Update(cmd()) + m = next.(Model) + if f.fresh != 1 || m.monitorCommands.menu.Revision != "fresh" || m.monitorPrompt.input.Value() != "/model " { + t.Fatal("explicit refresh lost the draft or skipped a fresh read") + } +} + +func TestMonitorLocalSuggestionsRemainUsableDuringColdDiscovery(t *testing.T) { + m, prompt := promptTestModel() + f := &suggestionClient{commandTestClient: &commandTestClient{promptTestClient: prompt}} + m.fetcher = f + m.focusMonitorPrompt() + m.monitorPrompt.input.SetValue("/theme") + if m.syncMonitorSuggestions() == nil || !m.monitorSuggestions.busy { + t.Fatal("fixture must have a pending discovery") + } + if choices := m.suggestionChoices(); len(choices) != 1 || choices[0].Label != "/theme" { + t.Fatal("local command is hidden until discovery finishes") + } + next, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + m = next.(Model) + if !m.monitorCommands.open || m.monitorCommands.menu.Path != "theme" || m.monitorCommands.busy || f.reads != 0 { + t.Fatal("cold discovery blocked a local command") + } +} diff --git a/internal/ui/monitor_commands.go b/internal/ui/monitor_commands.go index 0450745..547b40e 100644 --- a/internal/ui/monitor_commands.go +++ b/internal/ui/monitor_commands.go @@ -47,7 +47,7 @@ type monitorCommandsResult struct { err error } -func (m *Model) loadMonitorCommands(path string) tea.Cmd { +func (m *Model) loadMonitorCommands(path string, fresh ...bool) tea.Cmd { p := &m.monitorCommands p.reviewedChoice = "" p.until = time.Time{} @@ -113,9 +113,24 @@ func (m *Model) loadMonitorCommands(path string) tea.Cmd { p.hover = -1 p.request++ id, seq := p.session, p.request + force := len(fresh) > 0 && fresh[0] + cache, cached := c.(codex.CachedSessionCommandsClient) + if cached && !force { + if menu, hit := cache.CachedSessionCommands(id, path); hit { + // Use the same result handler for cached and asynchronously loaded + // menus, including restored drafts, selected options and input focus. + updated, cmd, _ := m.updateMonitorCommands(monitorCommandsResult{session: id, path: path, request: seq, menu: menu}) + *m = updated + return cmd + } + } return func() tea.Msg { ctx, cancel := context.WithTimeout(context.Background(), 12*time.Second) defer cancel() + if cached && force { + menu, err := cache.SessionCommandsFresh(ctx, id, path) + return monitorCommandsResult{session: id, path: path, request: seq, menu: menu, err: err} + } menu, err := c.SessionCommands(ctx, id, path) return monitorCommandsResult{session: id, path: path, request: seq, menu: menu, err: err} } @@ -795,7 +810,7 @@ func (m Model) updateMonitorCommands(msg tea.Msg) (Model, tea.Cmd, bool) { return m, cmd, true case "f": if !p.busy { - cmd := m.loadMonitorCommands(p.menu.Path) + cmd := m.loadMonitorCommands(p.menu.Path, true) return m, cmd, true } case "/": @@ -903,7 +918,7 @@ func (m Model) updateMonitorCommands(msg tea.Msg) (Model, tea.Cmd, bool) { cmd := m.backMonitorCommands() return m, cmd, true case "refresh": - cmd := m.loadMonitorCommands(p.menu.Path) + cmd := m.loadMonitorCommands(p.menu.Path, true) return m, cmd, true case "close": cmd := m.cancelMonitorCommands() diff --git a/internal/ui/monitor_commands_inline.go b/internal/ui/monitor_commands_inline.go index eabfdc4..422e0fc 100644 --- a/internal/ui/monitor_commands_inline.go +++ b/internal/ui/monitor_commands_inline.go @@ -72,7 +72,7 @@ func (m Model) updateInlineCommands(msg tea.Msg) (Model, tea.Cmd, bool) { case "ctrl+r": if !p.busy { p.restore = &monitorCommandLevel{selected: p.selected, input: p.input.Value(), draft: m.monitorPrompt.input.Value()} - cmd := m.loadMonitorCommands(p.menu.Path) + cmd := m.loadMonitorCommands(p.menu.Path, true) return m, cmd, true } return m, nil, true diff --git a/internal/ui/monitor_suggestions.go b/internal/ui/monitor_suggestions.go index 3f48d37..5f8dcbf 100644 --- a/internal/ui/monitor_suggestions.go +++ b/internal/ui/monitor_suggestions.go @@ -49,7 +49,13 @@ func (m *Model) syncMonitorSuggestions() tea.Cmd { *s = monitorSuggestionState{active: true, session: m.monitorPrompt.session, request: s.request + 1, menu: m.localCommandsMenu()} return nil } - *s = monitorSuggestionState{active: true, busy: true, session: m.monitorPrompt.session, request: s.request + 1} + *s = monitorSuggestionState{active: true, busy: true, session: m.monitorPrompt.session, request: s.request + 1, menu: m.localCommandsMenu()} + if cache, ok := c.(codex.CachedSessionCommandsClient); ok { + if menu, hit := cache.CachedSessionCommands(s.session, ""); hit { + s.menu, s.busy = m.withLocalCommands(menu), false + return nil + } + } id, seq := s.session, s.request return func() tea.Msg { ctx, cancel := context.WithTimeout(context.Background(), 12*time.Second) @@ -157,7 +163,7 @@ func (m Model) updateMonitorSuggestions(msg tea.Msg) (Model, tea.Cmd, bool) { s.selected = min(max(s.selected+d, 0), max(len(choices)-1, 0)) return m, nil, true case "tab", "enter": - if s.busy { + if s.busy && (len(choices) == 0 || !codex.IsLocalSessionCommand(choices[min(s.selected, len(choices)-1)].Next)) { return m, nil, true } if len(choices) == 0 { diff --git a/internal/web/control_commands.go b/internal/web/control_commands.go index 14c0e46..baccacd 100644 --- a/internal/web/control_commands.go +++ b/internal/web/control_commands.go @@ -16,11 +16,12 @@ type commandRequest struct { Path string `json:"path"` Revision string `json:"revision,omitempty"` Choice string `json:"choice,omitempty"` + Refresh bool `json:"refresh,omitempty"` } func (c *control) handleCommands(w http.ResponseWriter, r *http.Request, body actionRequest) { q := body.Command - if c.commands == nil || q == nil || body.Review != "" || body.Offer != "" || body.Choice != nil || len(body.Answers) != 0 || body.Schedule != nil || body.EditID != "" || body.SendID != "" || body.CancelID != "" || !codex.ValidSessionCommandPath(q.Path) || len(q.Revision) > 128 || len(q.Choice) > 128 || (q.Mode != "list" && q.Mode != "prepare" && q.Mode != "commit") { + if c.commands == nil || q == nil || body.Review != "" || body.Offer != "" || body.Choice != nil || len(body.Answers) != 0 || body.Schedule != nil || body.EditID != "" || body.SendID != "" || body.CancelID != "" || !codex.ValidSessionCommandPath(q.Path) || len(q.Revision) > 128 || len(q.Choice) > 128 || (q.Mode != "list" && q.Mode != "prepare" && q.Mode != "commit") || q.Refresh && q.Mode != "list" { http.Error(w, "Commands unavailable", 400) return } @@ -63,7 +64,11 @@ func (c *control) handleCommands(w http.ResponseWriter, r *http.Request, body ac _ = json.NewEncoder(w).Encode(map[string]string{"message": message}) return } - m, err := c.commands.SessionCommands(ctx, body.Session, q.Path) + read := c.commands.SessionCommands + if cache, ok := c.commands.(codex.CachedSessionCommandsClient); ok && (q.Refresh || q.Mode == "prepare") { + read = cache.SessionCommandsFresh + } + m, err := read(ctx, body.Session, q.Path) if err != nil { http.Error(w, "Command catalogue unavailable; use Codex", 409) return diff --git a/internal/web/control_commands_test.go b/internal/web/control_commands_test.go index 062fc5c..ebe9386 100644 --- a/internal/web/control_commands_test.go +++ b/internal/web/control_commands_test.go @@ -20,6 +20,42 @@ type commandsFake struct { picker bool } +type cachedCommandsFake struct { + *commandsFake + fresh int +} + +func (f *cachedCommandsFake) CachedSessionCommands(string, string) (codex.SessionCommandMenu, bool) { + return codex.SessionCommandMenu{}, false +} + +func (f *cachedCommandsFake) SessionCommandsFresh(ctx context.Context, id, path string) (codex.SessionCommandMenu, error) { + f.fresh++ + return f.commandsFake.SessionCommands(ctx, id, path) +} + +func TestCommandRefreshAndPreparationUseFreshCatalogue(t *testing.T) { + s, _, token := controlServer(t) + f := &cachedCommandsFake{commandsFake: &commandsFake{revision: "current"}} + s.control.commands = f + b := actionRequest{Session: "parent", Command: &commandRequest{Mode: "list", Path: "model"}} + if w := actionCall(s, token, "commands", b); w.Code != 200 || f.fresh != 0 { + t.Fatal("ordinary browsing did not use the cached client") + } + b.Command.Refresh = true + if w := actionCall(s, token, "commands", b); w.Code != 200 || f.fresh != 1 { + t.Fatal("explicit refresh did not bypass cache") + } + b.Command.Mode = "prepare" + if w := actionCall(s, token, "commands", b); w.Code != 400 { + t.Fatal("refresh flag accepted on a preparation request") + } + b.Command.Refresh, b.Command.Revision, b.Command.Choice = false, "current", "choice" + if w := actionCall(s, token, "commands", b); w.Code != 200 || f.fresh != 2 || f.calls != 0 { + t.Fatal("preparation did not freshly validate without writing") + } +} + func (f *commandsFake) SessionCommands(_ context.Context, _ string, path string) (codex.SessionCommandMenu, error) { f.lists++ f.listedPath = path diff --git a/internal/web/dist/assets/index-BWH3mrUB.js b/internal/web/dist/assets/index-D1LQiLkx.js similarity index 95% rename from internal/web/dist/assets/index-BWH3mrUB.js rename to internal/web/dist/assets/index-D1LQiLkx.js index 96305c6..3518d45 100644 --- a/internal/web/dist/assets/index-BWH3mrUB.js +++ b/internal/web/dist/assets/index-D1LQiLkx.js @@ -9,12 +9,12 @@ redemption order.

`),Va=J(` `,1),Ha=jr(``),Ua=jr(``),Wa=J(`
`),Ga=J(`

Cycle duration or reset date unavailable — position cannot be plotted.

`),Ka=J(`
EMPTYFULL
`),qa=J(`

`,1),Ja=J(`
`,1),Ya=J(`

`),Xa=J(`

`),Za=J(`

No quota windows reported yet.

`),Qa=J(`
`,1),$a=J(`

All reported windows are shown. API-equivalent learning and quota status scoring remain in the terminal for this first preview.

`,1),eo=J(` `,1);function to(e,t){Ue(t,!0);let n=Wi(t,`params`,19,()=>({})),r=F(()=>ea.filter(e=>e!==`thresholds`||!!$.data?.thresholds?.length)),i=L(Yt(Date.now())),a=F(()=>K(r).includes(ta(n().view))?ta(n().view):`bars`);Gi(()=>{let e=setInterval(()=>R(i,Date.now(),!0),1e3);return()=>clearInterval(e)});function o(e){return!e.duration||e.duration<=0||!e.reset?null:Math.max(0,Math.min(100,100*(1-(e.reset*1e3-K(i))/(e.duration*6e4))))}vn(()=>{aa.view=K(a),(n().view===`zone`||n().view===`consumption-pace`)&&Zi(`/quota/pace`)});function s(e){let t=e/100*Math.PI*2;return`M60 60 L60 14 A46 46 0 ${+(e>50)} 1 ${60+46*Math.sin(t)} ${60-46*Math.cos(t)} Z`}var c=eo(),l=B(c);Xr(l,21,()=>K(r),Kr,(e,t)=>{var n=Fa();let r;var i=V(n,!0);U(e=>{Q(n,`href`,`#/quota/`+K(t)),Q(n,`aria-current`,K(a)===K(t)?`page`:void 0),r=ci(n,1,``,null,r,{active:K(a)===K(t)}),X(i,e)},[()=>K(t)===`fuel`?`FUEL TANK`:K(t).toUpperCase()]),Y(e,n)}),P(l);var u=H(l,2),d=e=>{var t=$a(),n=B(t),r=e=>{Y(e,Ia())};Z(n,e=>{$.data.quotaError&&e(r)});var i=H(n,2),c=e=>{var t=La(),n=V(t);U(()=>X(n,`Last usage accounting issue: - ${$.data.usageDiagnostic??``}`)),Y(e,t)};Z(i,e=>{$.data.usageDiagnostic&&e(c)});var l=H(i,2),u=V(l),d=H(l,2),f=e=>{wa(e,{})},p=e=>{var t=Ba(),n=z(t),r=V(n),i=H(n,4),a=e=>{Y(e,Ra())};Z(i,e=>{$.data.credits.length||e(a)}),Xr(H(i,2),17,()=>$.data.credits,Kr,(e,t)=>{var n=za(),r=z(n),i=V(r),a=V(H(r,2),!0);P(n),U(e=>{X(i,`${(K(t).title||`Quota reset`)??``} // ${K(t).status??``}`),X(a,e)},[()=>K(t).expiryKnown?K(t).expires?`EXPIRES `+ma(K(t).expires):`Does not expire`:`Expiry information unavailable`]),Y(e,n)}),xe(2),P(t),U(()=>X(r,`RESET INVENTORY // ${$.data.creditCount??``} AVAILABLE`)),Y(e,t)},m=e=>{var t=Qa(),n=B(t);let r;Xr(n,21,()=>$.data.meters,Kr,(e,t)=>{let n=F(()=>o(K(t)));var r=Xa(),i=z(r),c=V(i,!0),l=H(i,2),u=z(l),d=e=>{var n=Va(),r=B(n),i=V(r),a=V(H(r));U(()=>{X(i,`FREE ${100-K(t).used}%`),X(a,`USED ${K(t).used??``}%`)}),Y(e,n)},f=e=>{var n=Va(),r=B(n),i=V(r),a=V(H(r));U(()=>{X(i,`USED ${K(t).used??``}%`),X(a,`FREE ${100-K(t).used}%`)}),Y(e,n)};Z(u,e=>{K(a)===`fuel`?e(d):e(f,-1)}),P(l);var p=H(l,2);let m;var h=z(p),g=e=>{var n=Wa(),r=z(n),i=H(z(r)),a=e=>{Y(e,Ha())},o=e=>{var n=Ua();U(e=>Q(n,`d`,e),[()=>s(K(t).used)]),Y(e,n)};Z(i,e=>{K(t).used>=100?e(a):K(t).used>0&&e(o,1)}),P(r),P(n),U(()=>Q(r,`aria-label`,`${K(t).used}% quota used`)),Y(e,n)},_=e=>{var r=Nr(),i=B(r),a=e=>{{let r=F(()=>K(t).trail||[]);Pa(e,{get used(){return K(t).used},get elapsed(){return K(n)},get trail(){return K(r)},get duration(){return K(t).duration}})}},o=e=>{Y(e,Ga())};Z(i,e=>{K(n)===null?e(o,-1):e(a)}),Y(e,r)},v=e=>{var r=Ja(),i=B(r),o=z(i);let s;P(i);var c=H(i,2),l=e=>{Y(e,Ka())};Z(c,e=>{K(a)===`fuel`&&e(l)});var u=H(c,2),d=e=>{var t=qa(),r=B(t),i=V(r),o=H(r,2),s=z(o);let c;P(o),U(e=>{X(i,`RESET CYCLE // ${e??``}% ELAPSED`),c=ui(s,``,c,{width:`${K(a)===`fuel`?100-K(n):K(n)}%`})},[()=>Math.floor(K(n))]),Y(e,t)};Z(u,e=>{K(n)!==null&&e(d)}),U(()=>{Q(i,`aria-label`,K(a)===`fuel`?`Fuel remaining`:`Quota used`),Q(i,`aria-valuenow`,K(a)===`fuel`?100-K(t).used:K(t).used),s=ui(o,``,s,{width:`${K(a)===`fuel`?100-K(t).used:K(t).used}%`})}),Y(e,r)};Z(h,e=>{K(a)===`pie`?e(g):K(a)===`pace`?e(_,1):e(v,-1)}),P(p);var y=H(p,2),b=V(y),x=H(y,2),S=e=>{var n=Ya(),r=V(n,!0);U(()=>X(r,K(t).details)),Y(e,n)};Z(x,e=>{K(t).details&&e(S)}),P(r),U(e=>{X(c,K(t).name),m=ci(p,1,`meter-graphic`,null,m,{"bar-graphic":K(a)===`bars`||K(a)===`fuel`}),X(b,`RESETS // ${e??``}`)},[()=>ma(K(t).reset)]),Y(e,r)}),P(n);var i=H(n,2),c=e=>{Y(e,Za())};Z(i,e=>{$.data.meters.length||e(c)}),U(()=>r=ci(n,1,`quota-grid`,null,r,{radial:K(a)===`pie`,zone:K(a)===`pace`})),Y(e,t)};Z(d,e=>{K(a)===`thresholds`?e(f):K(a)===`resets`?e(p,1):e(m,-1)}),xe(2),U(e=>X(u,`QUOTA // OBSERVED ${e??``}`),[()=>ma($.data.quotaAt)]),Y(e,t)};Z(u,e=>{$.data&&e(d)}),Y(e,c),We()}var no=J(`
`),ro=J(`

`,1);function io(e,t){Ue(t,!0);let n=Wi(t,`values`,19,()=>[]),r=Wi(t,`label`,3,`Token activity`),i=Wi(t,`capacity`,3,0),a=F(()=>Math.max(0,...n())),o=F(()=>i()>n().length?[...Array(i()-n().length).fill(0),...n()]:n());var s=ro(),c=B(s),l=V(c),u=H(c,2);Xr(u,21,()=>K(o),Kr,(e,t)=>{var n=no();let r;U((e,t)=>{Q(n,`title`,e),r=ui(n,``,r,{height:t})},[()=>K(t).toLocaleString(`en-GB`)+` tokens`,()=>`${100*K(t)/Math.max(1,K(a))}%`]),Y(e,n)}),P(u),U((e,t)=>{X(l,`SCALE // 0 — ${e??``} TOKENS`),Q(u,`aria-label`,t)},[()=>K(a).toLocaleString(`en-GB`),()=>`${r()}. Peak ${K(a).toLocaleString(`en-GB`)} tokens.`]),Y(e,s),We()}function ao(e){if(!e||![`LAST REPLY`,`LAST ACTIVITY`].includes(e.contextKind))return``;let t=e.text||e.contextKind===`LAST ACTIVITY`&&e.workingCommand||``;return t.trim()?t:``}var oo=J(` `),so=J(``),co=J(`

`),lo=J(`

`,1),uo=J(`

`),fo=J(`

Loading / sending…

`),po=J(`
`),mo=J(`

`),ho=J(`

 
`),go=J(`
`),_o=J(`
`),vo=J(`

Review expired. Press Enter to review again.

`),yo=J(`

`,1),bo=J(`

`,1),xo=J(`

No available matching commands.

`),So=J(` `,1),Co=J(`

`),wo=J(`

Review expired. Review the option again.

`),To=J(``),Eo=J(``),Do=J(``),Oo=J(``),ko=J(``),Ao=J(``),jo=J(``),Mo=J(`
`),No=J(`
`);function Po(e,t){let n=Pr();Ue(t,!0);let r=e=>{var t=Nr();Xr(B(t),19,()=>K(fe),e=>e.id,(e,t,r)=>{var i=so();let o;var s=z(i),c=V(s,!0),l=H(s),u=V(l),d=H(l),f=e=>{var n=oo(),r=V(n,!0);U(()=>X(r,K(t).help)),Y(e,n)};Z(d,e=>{(K(v)?.path===``||K(A))&&e(f)}),P(i),U((e,s)=>{Q(i,`id`,`${n}-${K(r)}`),Q(i,`role`,K(A)?`option`:void 0),Q(i,`aria-selected`,K(A)?K(T)===K(r):void 0),Q(i,`tabindex`,K(A)?-1:void 0),i.disabled=K(g)||K(he)&&!a(),Q(i,`title`,K(t).help),Q(i,`aria-label`,e),o=ci(i,1,`svelte-7bfwr9`,null,o,{suggested:K(T)===K(r)}),X(c,K(t).label),X(u,`${K(A)&&K(t).selected?` // CURRENT`:``}${s??``}`)},[()=>K(t).label+(K(A)&&K(t).selected?` // CURRENT`:``)+ie(K(t)),()=>ie(K(t))]),xr(`focus`,i,()=>{K(A)||_e(K(r))}),q(`click`,i,()=>{_e(K(r)),ye(K(t))}),Y(e,i)}),Y(e,t)},i=Wi(t,`query`,3,``),a=Wi(t,`localOnly`,3,!1),o=Wi(t,`oncopy`,3,()=>{}),s=Wi(t,`onchange`,3,()=>{}),c=Wi(t,`oncomplete`,3,e=>{}),l=[`statusline`,`usage`,`copy`,`theme`,`exit`,`quit`];function u(){return[{id:`local-statusline`,label:`/statusline`,help:`Choose fields for Codexometer’s detail footer.`,next:`statusline`},{id:`local-usage`,label:`/usage`,help:`Open Codexometer’s usage history. Quota and reset views remain available.`,next:`usage`},{id:`local-copy`,label:`/copy`,help:`Copy the selected session’s observed reply or activity with its Copy control.`,next:`copy`},{id:`local-theme`,label:`/theme`,help:`Open the existing Codexometer theme selector. Saved browser display preferences only.`,next:`theme`},{id:`local-exit`,label:`/exit`,help:`Leave session detail and return to Sessions. The local server remains running.`,next:`exit`},{id:`local-quit`,label:`/quit`,help:`Alias for /exit: return to Sessions. The local server remains running.`,next:`quit`}]}function d(){return{title:`/ COMMANDS`,help:`Local Codexometer commands remain available without session control.`,path:``,revision:``,choices:u()}}function f(){R(b,``),R(O,``),R(x,0),R(k,[],!0)}function p(){w++,R(g,!1),R(h,!1),R(E,i()),f(),R(_,``),K(ue)?s()():c()(i())}async function m(e){if(!l.includes(e))return!1;if(e===`copy`&&!ao($.data?.sessions.find(e=>e.id===t.session)))return R(_,`No reply or activity text is available to copy for this session.`),!0;if(e===`copy`){let e=o()();p(),await e}else p(),e===`usage`?location.hash=`/usage`:e===`theme`?sa.requests++:e===`statusline`?oa.open=!0:location.hash=`/sessions`;return!0}let h=L(!1),g=L(!1),_=L(``),v=L(null),y=L(null),b=L(``),x=L(0),S=L(Yt(Date.now())),C=L(``),w=0,T=L(0),E=L(null),D=L(void 0),ee=L(void 0),te=L(void 0),O=L(``),k=L(Yt([])),ne=F(()=>K(v)?.path.startsWith(`rename/`)||K(v)?.path.startsWith(`cd/`)),re=F(()=>!!K(y)&&!K(ne)||!!K(O)||K(k).length>0);function ie(e){return e.unavailable?e.unavailable.startsWith(`Waiting for idle.`)?` // WAITING FOR IDLE`:` // UNAVAILABLE`:e.next?` →`:e.action?``:` // HELP`}async function ae(e){K(v)&&!(K(ge)&&K(de))&&R(k,[...K(k),{path:K(v).path,index:K(T),input:K(C)}],!0),await N(e)}async function oe(){if(!K(re))return;if(w++,R(g,!1),R(b,``),R(x,0),R(_,``),K(O)){R(O,``),K(D)?.focus();return}if(K(y)&&!K(ne)){R(y,null),await lr(),K(D)?.focus();return}let e=K(k).at(-1);e&&(R(k,K(k).slice(0,-1),!0),await N(e.path,e))}function se(){K(v)&&K(C).trim()&&!K(g)&&!K(he)&&ae(K(v).path+`/`+encodeURIComponent(K(C).trim()))}let A=F(()=>K(h)&&!!K(v)?.picker&&!K(y)),ce=F(()=>K(h)&&!K(v)?.input&&!K(y)),le=setInterval(()=>{R(S,Date.now(),!0)},1e3);Ki(()=>{clearInterval(le),w++});let ue=F(()=>i().trim().startsWith(`/`)&&!i().trim().startsWith(`//`)),de=F(()=>K(v)?.path===``&&K(ue)?i().trim().slice(1).toLowerCase():``),fe=F(()=>(K(v)?.choices||[]).filter(e=>!K(de)||e.label.toLowerCase().startsWith(`/`+K(de)))),j=F(()=>K(y)||K(fe)[K(T)]),pe=F(()=>!!K(b)&&K(S)K(fe).find(e=>e.id===K(O))),he=F(()=>!$.connected||!!$.data?.sessionsError),ge=F(()=>K(h)&&K(ue)&&(!K(v)||K(v).path===``)&&!K(y));vn(()=>{i(),R(T,0)}),vn(()=>{let e=K(T);K(fe),K(ce)&&lr().then(()=>{let t=K(D)?.children[e];if(!t||!K(D))return;let n=K(D).getBoundingClientRect(),r=t.getBoundingClientRect();r.topn.bottom&&(K(D).scrollTop+=r.bottom-n.bottom)})});function _e(e){K(T)!==e&&K(v)?.picker&&(R(O,``),R(b,``),R(x,0),R(_,``),w++),R(T,e,!0)}function M(){w++,R(g,!1),R(h,!1),R(E,i()),f(),c()(i())}function ve(e){let t=!!K(ee)?.contains(e.target);if(!K(h)||!K(ce)&&!K(y)&&!K(v)?.input||!K(ue)&&!t||K(he)&&!a()||e.isComposing||e.ctrlKey||e.metaKey||e.altKey)return;if(K(v)?.input){e.key===`Escape`&&(e.preventDefault(),e.stopPropagation(),M());return}let n=e.key===`ArrowLeft`,r=e.key.toLowerCase()===`f`,i=e.key.toLowerCase()===`c`;if((i||r||n)&&!t||![`ArrowUp`,`ArrowDown`,`Tab`,`Enter`,`Escape`].includes(e.key)&&!i&&!r&&!n||e.key===`Tab`&&(K(y)||K(v)?.path!==``||e.shiftKey)||e.key===`Enter`&&e.target instanceof Element&&e.target.closest(`button`)&&!K(D)?.contains(e.target)&&!e.target.closest(`[data-command-confirm]`)||K(y)&&!t||K(y)&&[`ArrowUp`,`ArrowDown`].includes(e.key))return;if(e.preventDefault(),e.stopPropagation(),e.key===`Escape`){M();return}if(K(g)||e.repeat&&i)return;if(n){K(re)&&oe();return}if(r){N(K(v)?.path||``);return}if(i){(K(y)?.action||K(A)&&K(O))&&xe();return}if(K(y)){e.key===`Enter`&&K(y).action&&!K(pe)&&!K(_)&&be(K(y));return}if(e.key===`ArrowUp`||e.key===`ArrowDown`){_e(Math.min(Math.max(K(T)+(e.key===`ArrowUp`?-1:1),0),Math.max(K(fe).length-1,0))),!K(A)&&K(D)?.contains(e.target)&&lr().then(()=>(K(D)?.children[K(T)])?.focus());return}let o=K(fe)[K(T)];o&&(e.key===`Tab`?c()(o.label):K(A)?be(o):ye(o))}vn(()=>{K(he)&&!a()&&(w++,R(g,!1),f(),K(h)&&(R(h,!1),R(E,i())),R(v,null),R(y,null))}),vn(()=>{K(ue)&&!K(h)&&i()!==K(E)&&(!K(he)||a())&&(f(),R(h,!0),N(``))});async function N(e,n){let r=++w;if(K(v)&&R(v,{...K(v),path:e},!0),n&&R(C,n.input,!0),R(g,!0),R(y,null),R(O,``),R(b,``),R(x,0),R(_,``),a()){R(v,d(),!0),R(T,n?.index??0,!0),R(g,!1);return}try{let i=await va(`commands`,{session:t.session,command:{mode:`list`,path:e}});r===w&&((e===``||e===`help`)&&(i.choices=[...i.choices.filter(e=>!l.includes(e.label.replace(/^\//,``))),...u()]),R(v,i,!0),R(T,n?Math.min(n.index,Math.max(i.choices.length-1,0)):i.picker?Math.max(i.choices.findIndex(e=>e.selected),0):0,!0),i.picker&&lr().then(()=>K(D)?.focus()),R(C,n?.input??i.value??``,!0),n&&lr().then(()=>{i.input?K(ee)?.querySelector(`input`)?.focus():K(D)?.focus()}),(i.path.startsWith(`rename/`)||i.path.startsWith(`cd/`))&&i.choices.length===1&&(R(g,!1),ye(i.choices[0])))}catch{r===w&&(R(_,`Command catalogue unavailable. Use Codex.`),e===``||e===`help`?(R(v,d(),!0),R(_,``)):R(v,{title:`/`+e,help:``,path:e,revision:``,choices:[]},!0))}finally{r===w&&R(g,!1)}}async function ye(e){if(!(e.unavailable||K(g))&&!(e.next&&await m(e.next))&&!K(_)){if(K(v)?.picker){_e(K(fe).findIndex(t=>t.id===e.id)),K(D)?.focus();return}if(e.next){await ae(e.next);return}await be(e)}}async function be(e){if(K(g)||K(he)||!K(v)||e.unavailable||K(_))return;if(K(v).picker){if(K(pe)&&K(O)===e.id)return;R(O,e.id,!0)}else R(y,e,!0),lr().then(()=>K(te)?.focus());if(R(b,``),R(x,0),R(_,``),!e.action)return;let n=++w;R(g,!0);try{let r=await va(`commands`,{session:t.session,command:{mode:`prepare`,path:K(v).path,revision:K(v).revision,choice:e.id}});if(n===w){let e=Date.parse(r.expires);if(!r.confirmation||!Number.isFinite(e)||Date.now()>=e)throw Error(`Review confirmation unavailable`);R(b,r.confirmation,!0),R(x,e,!0)}}catch{n===w&&R(_,`Option changed or unavailable. Refresh before retrying.`)}finally{n===w&&R(g,!1)}}async function xe(){if(!K(b)||K(he)||K(g)||Date.now()>=K(x)||!K(v)||K(v).picker&&K(O)!==K(fe)[K(T)]?.id)return;let e=K(b);R(b,``),R(g,!0);let n=++w;try{let r=await va(`commands`,{session:t.session,confirmation:e,command:{mode:`commit`,path:K(v).path}});n===w&&(R(_,r.message,!0),R(h,!1),R(v,null),R(y,null),f(),R(E,null),s()())}catch{n===w&&R(_,`Change unconfirmed. Check Codex before retrying.`)}finally{n===w&&R(g,!1)}}var Se={handleKey:ve},Ce=No(),we=z(Ce),Te=H(we,2),Ee=e=>{var t=co(),n=V(t,!0);U(()=>X(n,K(_))),Y(e,t)};Z(Te,e=>{K(_)&&!K(h)&&e(Ee)});var De=H(Te,2),Oe=e=>{var i=Mo();let o;var s=z(i),c=e=>{var t=lo(),n=B(t),r=V(n,!0),i=V(H(n,2),!0);U(()=>{X(r,K(v)?.title||`/ COMMANDS`),X(i,K(v)?.help||`Loading live options…`)}),Y(e,t)};Z(s,e=>{K(ge)||e(c)});var l=H(s,2),u=e=>{var t=uo(),n=V(t,!0);U(()=>X(n,K(_))),Y(e,t)};Z(l,e=>{K(_)&&e(u)});var d=H(l,2),f=e=>{Y(e,fo())};Z(d,e=>{K(g)&&e(f)});var p=H(d,2),m=e=>{var t=po(),r=z(t),i=z(r),o=H(i);xi(o),P(r),P(t),U(e=>{Q(t,`id`,`${n}-input`),X(i,`${e??``} `),Q(o,`aria-label`,K(v).inputLabel||`Session name`),Q(o,`maxlength`,K(v).inputLimit||512),o.disabled=K(g)||K(he)&&!a()},[()=>(K(v).inputLabel||`Session name`).toUpperCase()]),xr(`submit`,t,e=>{e.preventDefault(),se()}),Ei(o,()=>K(C),e=>R(C,e)),Y(e,t)},h=e=>{var n=ho(),r=z(n),i=V(r,!0),a=H(r,2),o=V(a,!0),s=H(a,2),c=e=>{var n=mo(),r=z(n),i=H(r),a=e=>{Y(e,Mr(`Only this session's saved + ${$.data.usageDiagnostic??``}`)),Y(e,t)};Z(i,e=>{$.data.usageDiagnostic&&e(c)});var l=H(i,2),u=V(l),d=H(l,2),f=e=>{wa(e,{})},p=e=>{var t=Ba(),n=z(t),r=V(n),i=H(n,4),a=e=>{Y(e,Ra())};Z(i,e=>{$.data.credits.length||e(a)}),Xr(H(i,2),17,()=>$.data.credits,Kr,(e,t)=>{var n=za(),r=z(n),i=V(r),a=V(H(r,2),!0);P(n),U(e=>{X(i,`${(K(t).title||`Quota reset`)??``} // ${K(t).status??``}`),X(a,e)},[()=>K(t).expiryKnown?K(t).expires?`EXPIRES `+ma(K(t).expires):`Does not expire`:`Expiry information unavailable`]),Y(e,n)}),xe(2),P(t),U(()=>X(r,`RESET INVENTORY // ${$.data.creditCount??``} AVAILABLE`)),Y(e,t)},m=e=>{var t=Qa(),n=B(t);let r;Xr(n,21,()=>$.data.meters,Kr,(e,t)=>{let n=F(()=>o(K(t)));var r=Xa(),i=z(r),c=V(i,!0),l=H(i,2),u=z(l),d=e=>{var n=Va(),r=B(n),i=V(r),a=V(H(r));U(()=>{X(i,`FREE ${100-K(t).used}%`),X(a,`USED ${K(t).used??``}%`)}),Y(e,n)},f=e=>{var n=Va(),r=B(n),i=V(r),a=V(H(r));U(()=>{X(i,`USED ${K(t).used??``}%`),X(a,`FREE ${100-K(t).used}%`)}),Y(e,n)};Z(u,e=>{K(a)===`fuel`?e(d):e(f,-1)}),P(l);var p=H(l,2);let m;var h=z(p),g=e=>{var n=Wa(),r=z(n),i=H(z(r)),a=e=>{Y(e,Ha())},o=e=>{var n=Ua();U(e=>Q(n,`d`,e),[()=>s(K(t).used)]),Y(e,n)};Z(i,e=>{K(t).used>=100?e(a):K(t).used>0&&e(o,1)}),P(r),P(n),U(()=>Q(r,`aria-label`,`${K(t).used}% quota used`)),Y(e,n)},_=e=>{var r=Nr(),i=B(r),a=e=>{{let r=F(()=>K(t).trail||[]);Pa(e,{get used(){return K(t).used},get elapsed(){return K(n)},get trail(){return K(r)},get duration(){return K(t).duration}})}},o=e=>{Y(e,Ga())};Z(i,e=>{K(n)===null?e(o,-1):e(a)}),Y(e,r)},v=e=>{var r=Ja(),i=B(r),o=z(i);let s;P(i);var c=H(i,2),l=e=>{Y(e,Ka())};Z(c,e=>{K(a)===`fuel`&&e(l)});var u=H(c,2),d=e=>{var t=qa(),r=B(t),i=V(r),o=H(r,2),s=z(o);let c;P(o),U(e=>{X(i,`RESET CYCLE // ${e??``}% ELAPSED`),c=ui(s,``,c,{width:`${K(a)===`fuel`?100-K(n):K(n)}%`})},[()=>Math.floor(K(n))]),Y(e,t)};Z(u,e=>{K(n)!==null&&e(d)}),U(()=>{Q(i,`aria-label`,K(a)===`fuel`?`Fuel remaining`:`Quota used`),Q(i,`aria-valuenow`,K(a)===`fuel`?100-K(t).used:K(t).used),s=ui(o,``,s,{width:`${K(a)===`fuel`?100-K(t).used:K(t).used}%`})}),Y(e,r)};Z(h,e=>{K(a)===`pie`?e(g):K(a)===`pace`?e(_,1):e(v,-1)}),P(p);var y=H(p,2),b=V(y),x=H(y,2),S=e=>{var n=Ya(),r=V(n,!0);U(()=>X(r,K(t).details)),Y(e,n)};Z(x,e=>{K(t).details&&e(S)}),P(r),U(e=>{X(c,K(t).name),m=ci(p,1,`meter-graphic`,null,m,{"bar-graphic":K(a)===`bars`||K(a)===`fuel`}),X(b,`RESETS // ${e??``}`)},[()=>ma(K(t).reset)]),Y(e,r)}),P(n);var i=H(n,2),c=e=>{Y(e,Za())};Z(i,e=>{$.data.meters.length||e(c)}),U(()=>r=ci(n,1,`quota-grid`,null,r,{radial:K(a)===`pie`,zone:K(a)===`pace`})),Y(e,t)};Z(d,e=>{K(a)===`thresholds`?e(f):K(a)===`resets`?e(p,1):e(m,-1)}),xe(2),U(e=>X(u,`QUOTA // OBSERVED ${e??``}`),[()=>ma($.data.quotaAt)]),Y(e,t)};Z(u,e=>{$.data&&e(d)}),Y(e,c),We()}var no=J(`
`),ro=J(`

`,1);function io(e,t){Ue(t,!0);let n=Wi(t,`values`,19,()=>[]),r=Wi(t,`label`,3,`Token activity`),i=Wi(t,`capacity`,3,0),a=F(()=>Math.max(0,...n())),o=F(()=>i()>n().length?[...Array(i()-n().length).fill(0),...n()]:n());var s=ro(),c=B(s),l=V(c),u=H(c,2);Xr(u,21,()=>K(o),Kr,(e,t)=>{var n=no();let r;U((e,t)=>{Q(n,`title`,e),r=ui(n,``,r,{height:t})},[()=>K(t).toLocaleString(`en-GB`)+` tokens`,()=>`${100*K(t)/Math.max(1,K(a))}%`]),Y(e,n)}),P(u),U((e,t)=>{X(l,`SCALE // 0 — ${e??``} TOKENS`),Q(u,`aria-label`,t)},[()=>K(a).toLocaleString(`en-GB`),()=>`${r()}. Peak ${K(a).toLocaleString(`en-GB`)} tokens.`]),Y(e,s),We()}function ao(e){if(!e||![`LAST REPLY`,`LAST ACTIVITY`].includes(e.contextKind))return``;let t=e.text||e.contextKind===`LAST ACTIVITY`&&e.workingCommand||``;return t.trim()?t:``}var oo=J(` `),so=J(``),co=J(`

`),lo=J(`

`,1),uo=J(`

`),fo=J(`

Loading / sending…

`),po=J(`
`),mo=J(`

`),ho=J(`

 
`),go=J(`
`),_o=J(`
`),vo=J(`

Review expired. Press Enter to review again.

`),yo=J(`

`,1),bo=J(`

`,1),xo=J(`

No available matching commands.

`),So=J(` `,1),Co=J(`

`),wo=J(`

Review expired. Review the option again.

`),To=J(``),Eo=J(``),Do=J(``),Oo=J(``),ko=J(``),Ao=J(``),jo=J(``),Mo=J(`
`),No=J(`
`);function Po(e,t){let n=Pr();Ue(t,!0);let r=e=>{var t=Nr();Xr(B(t),19,()=>K(fe),e=>e.id,(e,t,r)=>{var i=so();let o;var s=z(i),c=V(s,!0),l=H(s),u=V(l),d=H(l),f=e=>{var n=oo(),r=V(n,!0);U(()=>X(r,K(t).help)),Y(e,n)};Z(d,e=>{(K(v)?.path===``||K(A))&&e(f)}),P(i),U((e,s)=>{Q(i,`id`,`${n}-${K(r)}`),Q(i,`role`,K(A)?`option`:void 0),Q(i,`aria-selected`,K(A)?K(T)===K(r):void 0),Q(i,`tabindex`,K(A)?-1:void 0),i.disabled=K(g)||K(he)&&!a(),Q(i,`title`,K(t).help),Q(i,`aria-label`,e),o=ci(i,1,`svelte-7bfwr9`,null,o,{suggested:K(T)===K(r)}),X(c,K(t).label),X(u,`${K(A)&&K(t).selected?` // CURRENT`:``}${s??``}`)},[()=>K(t).label+(K(A)&&K(t).selected?` // CURRENT`:``)+ie(K(t)),()=>ie(K(t))]),xr(`focus`,i,()=>{K(A)||_e(K(r))}),q(`click`,i,()=>{_e(K(r)),ye(K(t))}),Y(e,i)}),Y(e,t)},i=Wi(t,`query`,3,``),a=Wi(t,`localOnly`,3,!1),o=Wi(t,`oncopy`,3,()=>{}),s=Wi(t,`onchange`,3,()=>{}),c=Wi(t,`oncomplete`,3,e=>{}),l=[`statusline`,`usage`,`copy`,`theme`,`exit`,`quit`];function u(){return[{id:`local-statusline`,label:`/statusline`,help:`Choose fields for Codexometer’s detail footer.`,next:`statusline`},{id:`local-usage`,label:`/usage`,help:`Open Codexometer’s usage history. Quota and reset views remain available.`,next:`usage`},{id:`local-copy`,label:`/copy`,help:`Copy the selected session’s observed reply or activity with its Copy control.`,next:`copy`},{id:`local-theme`,label:`/theme`,help:`Open the existing Codexometer theme selector. Saved browser display preferences only.`,next:`theme`},{id:`local-exit`,label:`/exit`,help:`Leave session detail and return to Sessions. The local server remains running.`,next:`exit`},{id:`local-quit`,label:`/quit`,help:`Alias for /exit: return to Sessions. The local server remains running.`,next:`quit`}]}function d(){return{title:`/ COMMANDS`,help:`Local Codexometer commands remain available without session control.`,path:``,revision:``,choices:u()}}function f(){R(b,``),R(O,``),R(x,0),R(k,[],!0)}function p(){w++,R(g,!1),R(h,!1),R(E,i()),f(),R(_,``),K(ue)?s()():c()(i())}async function m(e){if(!l.includes(e))return!1;if(e===`copy`&&!ao($.data?.sessions.find(e=>e.id===t.session)))return R(_,`No reply or activity text is available to copy for this session.`),!0;if(e===`copy`){let e=o()();p(),await e}else p(),e===`usage`?location.hash=`/usage`:e===`theme`?sa.requests++:e===`statusline`?oa.open=!0:location.hash=`/sessions`;return!0}let h=L(!1),g=L(!1),_=L(``),v=L(null),y=L(null),b=L(``),x=L(0),S=L(Yt(Date.now())),C=L(``),w=0,T=L(0),E=L(null),D=L(void 0),ee=L(void 0),te=L(void 0),O=L(``),k=L(Yt([])),ne=F(()=>K(v)?.path.startsWith(`rename/`)||K(v)?.path.startsWith(`cd/`)),re=F(()=>!!K(y)&&!K(ne)||!!K(O)||K(k).length>0);function ie(e){return e.unavailable?e.unavailable.startsWith(`Waiting for idle.`)?` // WAITING FOR IDLE`:` // UNAVAILABLE`:e.next?` →`:e.action?``:` // HELP`}async function ae(e){K(v)&&!(K(ge)&&K(de))&&R(k,[...K(k),{path:K(v).path,index:K(T),input:K(C)}],!0),await N(e)}async function oe(){if(!K(re))return;if(w++,R(g,!1),R(b,``),R(x,0),R(_,``),K(O)){R(O,``),K(D)?.focus();return}if(K(y)&&!K(ne)){R(y,null),await lr(),K(D)?.focus();return}let e=K(k).at(-1);e&&(R(k,K(k).slice(0,-1),!0),await N(e.path,e))}function se(){K(v)&&K(C).trim()&&!K(g)&&!K(he)&&ae(K(v).path+`/`+encodeURIComponent(K(C).trim()))}let A=F(()=>K(h)&&!!K(v)?.picker&&!K(y)),ce=F(()=>K(h)&&!K(v)?.input&&!K(y)),le=setInterval(()=>{R(S,Date.now(),!0)},1e3);Ki(()=>{clearInterval(le),w++});let ue=F(()=>i().trim().startsWith(`/`)&&!i().trim().startsWith(`//`)),de=F(()=>K(v)?.path===``&&K(ue)?i().trim().slice(1).toLowerCase():``),fe=F(()=>(K(v)?.choices||[]).filter(e=>!K(de)||e.label.toLowerCase().startsWith(`/`+K(de)))),j=F(()=>K(y)||K(fe)[K(T)]),pe=F(()=>!!K(b)&&K(S)K(fe).find(e=>e.id===K(O))),he=F(()=>!$.connected||!!$.data?.sessionsError),ge=F(()=>K(h)&&K(ue)&&(!K(v)||K(v).path===``)&&!K(y));vn(()=>{i(),R(T,0)}),vn(()=>{let e=K(T);K(fe),K(ce)&&lr().then(()=>{let t=K(D)?.children[e];if(!t||!K(D))return;let n=K(D).getBoundingClientRect(),r=t.getBoundingClientRect();r.topn.bottom&&(K(D).scrollTop+=r.bottom-n.bottom)})});function _e(e){K(T)!==e&&K(v)?.picker&&(R(O,``),R(b,``),R(x,0),R(_,``),w++),R(T,e,!0)}function M(){w++,R(g,!1),R(h,!1),R(E,i()),f(),c()(i())}function ve(e){let t=!!K(ee)?.contains(e.target);if(!K(h)||!K(ce)&&!K(y)&&!K(v)?.input||!K(ue)&&!t||K(he)&&!a()||e.isComposing||e.ctrlKey||e.metaKey||e.altKey)return;if(K(v)?.input){e.key===`Escape`&&(e.preventDefault(),e.stopPropagation(),M());return}let n=e.key===`ArrowLeft`,r=e.key.toLowerCase()===`f`,i=e.key.toLowerCase()===`c`;if((i||r||n)&&!t||![`ArrowUp`,`ArrowDown`,`Tab`,`Enter`,`Escape`].includes(e.key)&&!i&&!r&&!n||e.key===`Tab`&&(K(y)||K(v)?.path!==``||e.shiftKey)||e.key===`Enter`&&e.target instanceof Element&&e.target.closest(`button`)&&!K(D)?.contains(e.target)&&!e.target.closest(`[data-command-confirm]`)||K(y)&&!t||K(y)&&[`ArrowUp`,`ArrowDown`].includes(e.key))return;if(e.preventDefault(),e.stopPropagation(),e.key===`Escape`){M();return}if(K(g)||e.repeat&&i)return;if(n){K(re)&&oe();return}if(r){N(K(v)?.path||``,void 0,!0);return}if(i){(K(y)?.action||K(A)&&K(O))&&xe();return}if(K(y)){e.key===`Enter`&&K(y).action&&!K(pe)&&!K(_)&&be(K(y));return}if(e.key===`ArrowUp`||e.key===`ArrowDown`){_e(Math.min(Math.max(K(T)+(e.key===`ArrowUp`?-1:1),0),Math.max(K(fe).length-1,0))),!K(A)&&K(D)?.contains(e.target)&&lr().then(()=>(K(D)?.children[K(T)])?.focus());return}let o=K(fe)[K(T)];o&&(e.key===`Tab`?c()(o.label):K(A)?be(o):ye(o))}vn(()=>{K(he)&&!a()&&(w++,R(g,!1),f(),K(h)&&(R(h,!1),R(E,i())),R(v,null),R(y,null))}),vn(()=>{K(ue)&&!K(h)&&i()!==K(E)&&(!K(he)||a())&&(f(),R(h,!0),N(``))});async function N(e,n,r=!1){let i=++w;if(K(v)&&R(v,{...K(v),path:e},!0),n&&R(C,n.input,!0),R(g,!0),R(y,null),R(O,``),R(b,``),R(x,0),R(_,``),a()){R(v,d(),!0),R(T,n?.index??0,!0),R(g,!1);return}try{let a=await va(`commands`,{session:t.session,command:{mode:`list`,path:e,...r?{refresh:!0}:{}}});i===w&&((e===``||e===`help`)&&(a.choices=[...a.choices.filter(e=>!l.includes(e.label.replace(/^\//,``))),...u()]),R(v,a,!0),R(T,n?Math.min(n.index,Math.max(a.choices.length-1,0)):a.picker?Math.max(a.choices.findIndex(e=>e.selected),0):0,!0),a.picker&&lr().then(()=>K(D)?.focus()),R(C,n?.input??a.value??``,!0),n&&lr().then(()=>{a.input?K(ee)?.querySelector(`input`)?.focus():K(D)?.focus()}),(a.path.startsWith(`rename/`)||a.path.startsWith(`cd/`))&&a.choices.length===1&&(R(g,!1),ye(a.choices[0])))}catch{i===w&&(R(_,`Command catalogue unavailable. Use Codex.`),e===``||e===`help`?(R(v,d(),!0),R(_,``)):R(v,{title:`/`+e,help:``,path:e,revision:``,choices:[]},!0))}finally{i===w&&R(g,!1)}}async function ye(e){if(!(e.unavailable||K(g))&&!(e.next&&await m(e.next))&&!K(_)){if(K(v)?.picker){_e(K(fe).findIndex(t=>t.id===e.id)),K(D)?.focus();return}if(e.next){await ae(e.next);return}await be(e)}}async function be(e){if(K(g)||K(he)||!K(v)||e.unavailable||K(_))return;if(K(v).picker){if(K(pe)&&K(O)===e.id)return;R(O,e.id,!0)}else R(y,e,!0),lr().then(()=>K(te)?.focus());if(R(b,``),R(x,0),R(_,``),!e.action)return;let n=++w;R(g,!0);try{let r=await va(`commands`,{session:t.session,command:{mode:`prepare`,path:K(v).path,revision:K(v).revision,choice:e.id}});if(n===w){let e=Date.parse(r.expires);if(!r.confirmation||!Number.isFinite(e)||Date.now()>=e)throw Error(`Review confirmation unavailable`);R(b,r.confirmation,!0),R(x,e,!0)}}catch{n===w&&R(_,`Option changed or unavailable. Refresh before retrying.`)}finally{n===w&&R(g,!1)}}async function xe(){if(!K(b)||K(he)||K(g)||Date.now()>=K(x)||!K(v)||K(v).picker&&K(O)!==K(fe)[K(T)]?.id)return;let e=K(b);R(b,``),R(g,!0);let n=++w;try{let r=await va(`commands`,{session:t.session,confirmation:e,command:{mode:`commit`,path:K(v).path}});n===w&&(R(_,r.message,!0),R(h,!1),R(v,null),R(y,null),f(),R(E,null),s()())}catch{n===w&&R(_,`Change unconfirmed. Check Codex before retrying.`)}finally{n===w&&R(g,!1)}}var Se={handleKey:ve},Ce=No(),we=z(Ce),Te=H(we,2),Ee=e=>{var t=co(),n=V(t,!0);U(()=>X(n,K(_))),Y(e,t)};Z(Te,e=>{K(_)&&!K(h)&&e(Ee)});var De=H(Te,2),Oe=e=>{var i=Mo();let o;var s=z(i),c=e=>{var t=lo(),n=B(t),r=V(n,!0),i=V(H(n,2),!0);U(()=>{X(r,K(v)?.title||`/ COMMANDS`),X(i,K(v)?.help||`Loading live options…`)}),Y(e,t)};Z(s,e=>{K(ge)||e(c)});var l=H(s,2),u=e=>{var t=uo(),n=V(t,!0);U(()=>X(n,K(_))),Y(e,t)};Z(l,e=>{K(_)&&e(u)});var d=H(l,2),f=e=>{Y(e,fo())};Z(d,e=>{K(g)&&e(f)});var p=H(d,2),m=e=>{var t=po(),r=z(t),i=z(r),o=H(i);xi(o),P(r),P(t),U(e=>{Q(t,`id`,`${n}-input`),X(i,`${e??``} `),Q(o,`aria-label`,K(v).inputLabel||`Session name`),Q(o,`maxlength`,K(v).inputLimit||512),o.disabled=K(g)||K(he)&&!a()},[()=>(K(v).inputLabel||`Session name`).toUpperCase()]),xr(`submit`,t,e=>{e.preventDefault(),se()}),Ei(o,()=>K(C),e=>R(C,e)),Y(e,t)},h=e=>{var n=ho(),r=z(n),i=V(r,!0),a=H(r,2),o=V(a,!0),s=H(a,2),c=e=>{var n=mo(),r=z(n),i=H(r),a=e=>{Y(e,Mr(`Only this session's saved name changes.`))},o=F(()=>K(v)?.path.startsWith(`rename/`)),s=e=>{Y(e,Mr(`Only this session’s directory changes; project configuration is not reloaded.`))},c=F(()=>K(v)?.path.startsWith(`cd/`)),l=e=>{Y(e,Mr(`No change sent yet. Model, reasoning and speed settings can change token usage and cost. Changes affect subsequent turns of this session, not global defaults. Automatic quota thresholds - may later supersede model settings.`))};Z(i,e=>{K(o)?e(a):K(c)?e(s,1):e(l,-1)}),P(n),U(()=>X(r,`TARGET // ${t.session??``}. `)),Y(e,n)};Z(s,e=>{K(y).action&&e(c)}),P(n),Ii(n,e=>R(te,e),()=>K(te)),U(()=>{X(i,K(y).label),X(o,K(y).help||`No additional help supplied by Codex.`)}),Y(e,n)},w=e=>{var t=So(),i=B(t),a=e=>{var t=go(),i=z(t);r(i),P(t),Ii(t,e=>R(D,e),()=>K(D)),U(()=>Q(t,`aria-activedescendant`,`${n}-${K(T)}`)),Y(e,t)},o=e=>{var t=_o();let n;var i=z(t);r(i),P(t),Ii(t,e=>R(D,e),()=>K(D)),U(()=>n=ci(t,1,`command-options svelte-7bfwr9`,null,n,{vertical:K(ge)})),Y(e,t)};Z(i,e=>{K(A)?e(a):e(o,-1)});var s=H(i,2),c=e=>{var t=bo(),n=B(t),r=V(n,!0),i=H(n,2),a=e=>{var t=yo(),n=B(t),r=V(n),i=H(n,2),a=e=>{Y(e,vo())};Z(i,e=>{K(b)&&K(S)>=K(x)&&e(a)}),U(()=>X(r,`REVIEW // ${K(me).label??``}. No change sent yet.`)),Y(e,t)};Z(i,e=>{K(me)&&e(a)}),U(()=>X(r,K(fe)[K(T)]?.help)),Y(e,t)};Z(s,e=>{K(A)&&e(c)});var l=H(s,2),u=e=>{Y(e,xo())};Z(l,e=>{!K(g)&&!K(fe).length&&e(u)}),Y(e,t)};Z(p,e=>{K(v)?.input?e(m):K(y)?e(h,1):e(w,-1)});var E=H(p,2),O=e=>{var t=Co(),n=V(t,!0);U(()=>X(n,K(j).unavailable)),Y(e,t)};Z(E,e=>{K(j)?.unavailable&&e(O)});var k=H(E,2),ne=e=>{Y(e,wo())};Z(k,e=>{K(b)&&K(S)>=K(x)&&!K(A)&&e(ne)});var ie=H(k,2),ae=z(ie),ce=e=>{var t=Nr(),r=B(t),i=e=>{var t=To();U(e=>{Q(t,`form`,`${n}-input`),t.disabled=e},[()=>K(g)||K(he)||!K(C).trim()]),Y(e,t)},o=e=>{var t=Nr(),n=B(t),r=e=>{var t=Eo();U(()=>t.disabled=K(g)||K(he)&&!a()),q(`click`,t,xe),Y(e,t)},i=e=>{var t=Do();U(()=>t.disabled=K(g)||K(he)&&!a()),q(`click`,t,()=>{K(j)&&be(K(j))}),Y(e,t)};Z(n,e=>{K(pe)?e(r):e(i,-1)}),Y(e,t)},s=e=>{var t=Oo(),n=V(t);U(()=>{t.disabled=K(g)||K(he)&&!a(),X(n,`ENTER ${K(j).next?`OPEN`:`HELP`}`)}),q(`click`,t,()=>{K(j)&&ye(K(j))}),Y(e,t)};Z(r,e=>{K(v)?.input?e(i):K(j)?.action&&!K(j).unavailable?e(o,1):!K(y)&&K(j)&&!K(j).unavailable&&e(s,2)}),Y(e,t)};Z(ae,e=>{K(_)||e(ce)});var le=H(ae,2),ue=e=>{var t=ko();U(()=>t.disabled=K(g)),q(`click`,t,oe),Y(e,t)};Z(le,e=>{K(re)&&e(ue)});var de=H(le,2),_e=e=>{var t=Ao();U(()=>t.disabled=K(g)),q(`click`,t,()=>N(K(v)?.path||``)),Y(e,t)};Z(de,e=>{K(ge)||e(_e)});var Se=H(de,2);P(ie);var Ce=H(ie,2),we=e=>{var t=jo(),n=V(t);U(()=>X(n,`↑/↓ select${K(ge)?` · Tab complete`:``}`)),Y(e,t)};Z(Ce,e=>{!K(v)?.input&&!K(y)&&e(we)}),P(i),Ii(i,e=>R(ee,e),()=>K(ee)),U(()=>{Q(i,`aria-label`,K(ge)?`Slash command suggestions`:`Session command options`),o=ci(i,1,`svelte-7bfwr9`,null,o,{"command-popup":K(ge)})}),q(`keydown`,i,ve),q(`click`,Se,M),Y(e,i)};return Z(De,e=>{K(h)&&e(Oe)}),P(Ce),U(()=>we.disabled=K(he)&&!a()||K(g)),q(`click`,we,()=>{R(h,!K(h)),R(E,K(h)?null:i(),!0),R(b,``),K(h)?(R(k,[],!0),N(``)):M()}),Y(e,Ce),We(Se)}Sr([`click`,`keydown`]);var Fo=J(` `,1),Io=J(`
`),Lo=J(`
/
`);function Ro(e,t){Ue(t,!0);let n=F(()=>t.lines.filter(e=>e.kind===`addition`).length),r=F(()=>t.lines.filter(e=>e.kind===`removal`).length);var i=Lo(),a=z(i),o=z(a),s=V(o),c=V(H(o,2));P(a),Xr(H(a,2),17,()=>t.lines,Kr,(e,t)=>{var n=Io();let r;var i=z(n),a=e=>{var n=Fo(),r=B(n),i=V(r,!0),a=V(H(r),!0);U(()=>{X(i,K(t).old||``),X(a,K(t).new||``)}),Y(e,n)};Z(i,e=>{K(t).kind!==`heading`&&K(t).kind!==`metadata`&&e(a)});var o=V(H(i),!0);P(n),U(()=>{r=ci(n,1,`diff-line svelte-x4gtb6`,null,r,{added:K(t).kind===`addition`,removed:K(t).kind===`removal`,heading:K(t).kind===`heading`}),X(o,K(t).text)}),Y(e,n)}),P(i),U(()=>{Q(a,`aria-label`,`${K(n)} lines added, ${K(r)} lines removed`),X(s,`+${K(n)??``}`),X(c,`−${K(r)??``}`)}),Y(e,i),We()}var zo=J(`

`),Bo=J(`

Send this saved prompt now? This starts Codex work and consumes quota.

`,1),Vo=J(``),Ho=J(`

 

In memory only. Follows session model, reasoning and speed. Closing + may later supersede model settings.`))};Z(i,e=>{K(o)?e(a):K(c)?e(s,1):e(l,-1)}),P(n),U(()=>X(r,`TARGET // ${t.session??``}. `)),Y(e,n)};Z(s,e=>{K(y).action&&e(c)}),P(n),Ii(n,e=>R(te,e),()=>K(te)),U(()=>{X(i,K(y).label),X(o,K(y).help||`No additional help supplied by Codex.`)}),Y(e,n)},w=e=>{var t=So(),i=B(t),a=e=>{var t=go(),i=z(t);r(i),P(t),Ii(t,e=>R(D,e),()=>K(D)),U(()=>Q(t,`aria-activedescendant`,`${n}-${K(T)}`)),Y(e,t)},o=e=>{var t=_o();let n;var i=z(t);r(i),P(t),Ii(t,e=>R(D,e),()=>K(D)),U(()=>n=ci(t,1,`command-options svelte-7bfwr9`,null,n,{vertical:K(ge)})),Y(e,t)};Z(i,e=>{K(A)?e(a):e(o,-1)});var s=H(i,2),c=e=>{var t=bo(),n=B(t),r=V(n,!0),i=H(n,2),a=e=>{var t=yo(),n=B(t),r=V(n),i=H(n,2),a=e=>{Y(e,vo())};Z(i,e=>{K(b)&&K(S)>=K(x)&&e(a)}),U(()=>X(r,`REVIEW // ${K(me).label??``}. No change sent yet.`)),Y(e,t)};Z(i,e=>{K(me)&&e(a)}),U(()=>X(r,K(fe)[K(T)]?.help)),Y(e,t)};Z(s,e=>{K(A)&&e(c)});var l=H(s,2),u=e=>{Y(e,xo())};Z(l,e=>{!K(g)&&!K(fe).length&&e(u)}),Y(e,t)};Z(p,e=>{K(v)?.input?e(m):K(y)?e(h,1):e(w,-1)});var E=H(p,2),O=e=>{var t=Co(),n=V(t,!0);U(()=>X(n,K(j).unavailable)),Y(e,t)};Z(E,e=>{K(j)?.unavailable&&e(O)});var k=H(E,2),ne=e=>{Y(e,wo())};Z(k,e=>{K(b)&&K(S)>=K(x)&&!K(A)&&e(ne)});var ie=H(k,2),ae=z(ie),ce=e=>{var t=Nr(),r=B(t),i=e=>{var t=To();U(e=>{Q(t,`form`,`${n}-input`),t.disabled=e},[()=>K(g)||K(he)||!K(C).trim()]),Y(e,t)},o=e=>{var t=Nr(),n=B(t),r=e=>{var t=Eo();U(()=>t.disabled=K(g)||K(he)&&!a()),q(`click`,t,xe),Y(e,t)},i=e=>{var t=Do();U(()=>t.disabled=K(g)||K(he)&&!a()),q(`click`,t,()=>{K(j)&&be(K(j))}),Y(e,t)};Z(n,e=>{K(pe)?e(r):e(i,-1)}),Y(e,t)},s=e=>{var t=Oo(),n=V(t);U(()=>{t.disabled=K(g)||K(he)&&!a(),X(n,`ENTER ${K(j).next?`OPEN`:`HELP`}`)}),q(`click`,t,()=>{K(j)&&ye(K(j))}),Y(e,t)};Z(r,e=>{K(v)?.input?e(i):K(j)?.action&&!K(j).unavailable?e(o,1):!K(y)&&K(j)&&!K(j).unavailable&&e(s,2)}),Y(e,t)};Z(ae,e=>{K(_)||e(ce)});var le=H(ae,2),ue=e=>{var t=ko();U(()=>t.disabled=K(g)),q(`click`,t,oe),Y(e,t)};Z(le,e=>{K(re)&&e(ue)});var de=H(le,2),_e=e=>{var t=Ao();U(()=>t.disabled=K(g)),q(`click`,t,()=>N(K(v)?.path||``,void 0,!0)),Y(e,t)};Z(de,e=>{K(ge)||e(_e)});var Se=H(de,2);P(ie);var Ce=H(ie,2),we=e=>{var t=jo(),n=V(t);U(()=>X(n,`↑/↓ select${K(ge)?` · Tab complete`:``}`)),Y(e,t)};Z(Ce,e=>{!K(v)?.input&&!K(y)&&e(we)}),P(i),Ii(i,e=>R(ee,e),()=>K(ee)),U(()=>{Q(i,`aria-label`,K(ge)?`Slash command suggestions`:`Session command options`),o=ci(i,1,`svelte-7bfwr9`,null,o,{"command-popup":K(ge)})}),q(`keydown`,i,ve),q(`click`,Se,M),Y(e,i)};return Z(De,e=>{K(h)&&e(Oe)}),P(Ce),U(()=>we.disabled=K(he)&&!a()||K(g)),q(`click`,we,()=>{R(h,!K(h)),R(E,K(h)?null:i(),!0),R(b,``),K(h)?(R(k,[],!0),N(``)):M()}),Y(e,Ce),We(Se)}Sr([`click`,`keydown`]);var Fo=J(` `,1),Io=J(`

`),Lo=J(`
/
`);function Ro(e,t){Ue(t,!0);let n=F(()=>t.lines.filter(e=>e.kind===`addition`).length),r=F(()=>t.lines.filter(e=>e.kind===`removal`).length);var i=Lo(),a=z(i),o=z(a),s=V(o),c=V(H(o,2));P(a),Xr(H(a,2),17,()=>t.lines,Kr,(e,t)=>{var n=Io();let r;var i=z(n),a=e=>{var n=Fo(),r=B(n),i=V(r,!0),a=V(H(r),!0);U(()=>{X(i,K(t).old||``),X(a,K(t).new||``)}),Y(e,n)};Z(i,e=>{K(t).kind!==`heading`&&K(t).kind!==`metadata`&&e(a)});var o=V(H(i),!0);P(n),U(()=>{r=ci(n,1,`diff-line svelte-x4gtb6`,null,r,{added:K(t).kind===`addition`,removed:K(t).kind===`removal`,heading:K(t).kind===`heading`}),X(o,K(t).text)}),Y(e,n)}),P(i),U(()=>{Q(a,`aria-label`,`${K(n)} lines added, ${K(r)} lines removed`),X(s,`+${K(n)??``}`),X(c,`−${K(r)??``}`)}),Y(e,i),We()}var zo=J(`

`),Bo=J(`

Send this saved prompt now? This starts Codex work and consumes quota.

`,1),Vo=J(``),Ho=J(`

 

In memory only. Follows session model, reasoning and speed. Closing Codexometer cancels pending work.

`),Uo=J(`

CURRENT PROFILE

MODEL / REASONING LEVEL / SPEED

 

PROPOSED PROFILE

MODEL / REASONING LEVEL / SPEED

 

Applied settings remain after Codexometer closes.

`,1),Wo=J(`

`),Go=J(`

 
`,1),Ko=J(`

Command unavailable from this observation. Open Codex to inspect the request.

`),qo=J(`

Session controls temporarily unavailable. Check Codex for current state.

`),Jo=J(`

Checking session controls…

`),Yo=J(`

`),Xo=J(`
About browser controls

Controls require a supported live request from a connected shared app-server session. Local observations alone cannot provide them.

`),Zo=J(` `,1),Qo=J(` `),$o=J(`Grants permission beyond this one action. Check the scope diff --git a/internal/web/dist/index.html b/internal/web/dist/index.html index 07fb2a8..ebf4529 100644 --- a/internal/web/dist/index.html +++ b/internal/web/dist/index.html @@ -5,7 +5,7 @@ Codexometer // Experimental web - + diff --git a/web/src/SessionCommands.svelte b/web/src/SessionCommands.svelte index b0eb77e..e5646bb 100644 --- a/web/src/SessionCommands.svelte +++ b/web/src/SessionCommands.svelte @@ -329,7 +329,7 @@ return; } if (refreshKey) { - void load(menu?.path || ''); + void load(menu?.path || '', undefined, true); return; } if (confirmKey) { @@ -388,7 +388,7 @@ void load(''); } }); - async function load(path: string, restore?: Level) { + async function load(path: string, restore?: Level, refresh = false) { const seq = ++request; if (menu) menu = { ...menu, path }; if (restore) name = restore.input; @@ -407,7 +407,7 @@ try { const result = await controlRequest('commands', { session, - command: { mode: 'list', path }, + command: { mode: 'list', path, ...(refresh ? { refresh: true } : {}) }, }); if (seq === request) { if (path === '' || path === 'help') @@ -739,7 +739,8 @@ {#if canBack}{/if} {#if !suggesting} load(menu?.path || '', undefined, true)} + >F REFRESH OPTIONS{/if} diff --git a/web/tests/browser.spec.ts b/web/tests/browser.spec.ts index eff53aa..e1c8b0f 100644 --- a/web/tests/browser.spec.ts +++ b/web/tests/browser.spec.ts @@ -4298,9 +4298,15 @@ test('blocked speed options explain availability and refresh without offering re await list.press('c'); expect(calls.every((c) => c.mode === 'list')).toBe(true); finishTurn(); + const refreshed = page.waitForRequest((request) => { + if (!request.url().endsWith('/api/control/commands')) return false; + const body = request.postDataJSON(); + return body.command?.mode === 'list' && body.command.refresh === true; + }); await panel .getByRole('button', { name: 'F REFRESH OPTIONS', exact: true }) .click(); + await refreshed; await expect( panel.getByRole('button', { name: 'ENTER REVIEW', exact: true }), ).toBeEnabled(); From 8e65e2f013dc505bf30702cb25c8ef655f5ab6d7 Mon Sep 17 00:00:00 2001 From: merefield Date: Tue, 20 Oct 2026 14:43:34 +0100 Subject: [PATCH 2/3] FIX: preserve menu caches for unchanged settings notifications --- internal/codex/session_commands_unix.go | 4 +++- internal/codex/session_commands_unix_test.go | 13 ++++++++++--- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/internal/codex/session_commands_unix.go b/internal/codex/session_commands_unix.go index 62558be..7a621ed 100644 --- a/internal/codex/session_commands_unix.go +++ b/internal/codex/session_commands_unix.go @@ -39,7 +39,9 @@ func (p *daemonStatusProvider) sessionCommandScope(id, path string) string { if path == "" || path == "help" { status = sessionRuntimeUnknown // Root names/help survive ordinary turn transitions. } - return commandDigest([]any{p.commandGeneration, status, p.settingsVersions[id], p.threadDirectories[id], p.threadSettings[id]}) + // Resuming a thread can repeat its settings notification. Invalidate on + // effective values, not notification counters, so warming can settle. + return commandDigest([]any{p.commandGeneration, status, p.threadDirectories[id], p.threadSettings[id]}) } func commandDigest(value any) string { diff --git a/internal/codex/session_commands_unix_test.go b/internal/codex/session_commands_unix_test.go index a262e11..c72e011 100644 --- a/internal/codex/session_commands_unix_test.go +++ b/internal/codex/session_commands_unix_test.go @@ -4,6 +4,7 @@ package codex import ( "context" + "encoding/json" "fmt" "net/url" "strings" @@ -87,12 +88,18 @@ func TestCachedCommandClientRevalidatesWritesAndSettingsScope(t *testing.T) { if _, err := c.SessionCommands(ctx, "one", "model/small"); err != nil { t.Fatal(err) } - p.mu.Lock() - p.settingsVersions["one"]++ - p.mu.Unlock() + settings := json.RawMessage(`{"threadId":"one","threadSettings":{"model":"large","effort":"high","serviceTier":null}}`) + p.handleNotification("thread/settings/updated", settings) if _, ok := c.CachedSessionCommands("one", "model/small"); ok { t.Fatal("settings change did not invalidate cached options immediately") } + if _, err := c.SessionCommands(ctx, "one", "model/small"); err != nil { + t.Fatal(err) + } + p.handleNotification("thread/settings/updated", settings) + if _, ok := c.CachedSessionCommands("one", "model/small"); !ok { + t.Fatal("repeated unchanged settings notification invalidated the cache") + } } func TestCommandDiscoveryRetainsFetchedSubmenus(t *testing.T) { From f9e30fc56b981d5f3b0a7255bd784eff8ded8f77 Mon Sep 17 00:00:00 2001 From: merefield Date: Tue, 20 Oct 2026 17:27:05 +0100 Subject: [PATCH 3/3] FIX: preserve command menu validity across sessions and turns --- internal/codex/daemon_status_unix.go | 28 ++++- internal/codex/session_command_cache.go | 47 ++++++--- internal/codex/session_command_cache_test.go | 103 ++++++++++++++++++- internal/codex/session_commands_unix.go | 11 +- internal/codex/session_commands_unix_test.go | 61 +++++++++-- 5 files changed, 222 insertions(+), 28 deletions(-) diff --git a/internal/codex/daemon_status_unix.go b/internal/codex/daemon_status_unix.go index 27fb151..7eba0a4 100644 --- a/internal/codex/daemon_status_unix.go +++ b/internal/codex/daemon_status_unix.go @@ -22,11 +22,12 @@ const ( ) type daemonStatusProvider struct { - commandGeneration uint64 - queueRevision uint64 - queueCapabilities map[string]queueCapability - contexts map[string]*daemonContextState - socketPath string + commandGeneration uint64 + commandTransitions map[string]uint64 + queueRevision uint64 + queueCapabilities map[string]queueCapability + contexts map[string]*daemonContextState + socketPath string mu sync.Mutex connection *websocket.Conn @@ -122,6 +123,11 @@ func (p *daemonStatusProvider) Fetch(ctx context.Context, threadIDs []string) (s p.mu.Lock() p.lastStatusAt = time.Now() p.statusThreads = stringStructSet(threadIDs) + for id, status := range statuses { + if p.statuses[id] != status { + p.bumpCommandTransitionLocked(id) + } + } p.statuses = cloneRuntimeStatuses(statuses) snapshot := p.statusSnapshotLocked(threadIDs) p.mu.Unlock() @@ -253,6 +259,7 @@ func (p *daemonStatusProvider) ensureConnected(ctx context.Context) error { } p.connection = connection p.commandGeneration++ + p.commandTransitions = make(map[string]uint64) p.pending = make(map[int64]chan daemonEnvelope) p.subscribed = make(map[string]struct{}) p.reroutedTurns = make(map[daemonTurnKey]string) @@ -423,6 +430,16 @@ func (p *daemonStatusProvider) readLoop(connection *websocket.Conn) { } func (p *daemonStatusProvider) handleNotification(method string, params json.RawMessage) { + // Turn events catch complete cycles between status polls. The counter is + // monotonic for this session and connection, so an old scope cannot revive. + if method == "turn/started" || method == "turn/completed" || method == "turn/interrupted" { + var event struct{ ThreadID string } + if json.Unmarshal(params, &event) == nil && event.ThreadID != "" { + p.mu.Lock() + p.bumpCommandTransitionLocked(event.ThreadID) + p.mu.Unlock() + } + } switch method { case "thread/settings/updated": var notification struct { @@ -538,6 +555,7 @@ func (p *daemonStatusProvider) disconnect(connection *websocket.Conn) { p.lastStatusAt = time.Time{} p.statusThreads = nil p.statuses = nil + p.commandTransitions = nil p.threadSettings = nil p.threadDirectories = nil if p.settingsSignal != nil { diff --git a/internal/codex/session_command_cache.go b/internal/codex/session_command_cache.go index 479de5f..c35eaa4 100644 --- a/internal/codex/session_command_cache.go +++ b/internal/codex/session_command_cache.go @@ -34,20 +34,23 @@ type commandMenuObservation struct { // A path-indexed tree of display metadata. Actions always go to the original // client, which validates a freshly read revision before sending any write. type sessionCommandCache struct { - mu sync.Mutex - source SessionCommandsClient - entries map[commandMenuKey]commandMenuEntry - observations map[string]commandMenuObservation - generation uint64 - reads singleflight.Group - workers chan struct{} - warmers chan struct{} - now func() time.Time + mu sync.Mutex + source SessionCommandsClient + entries map[commandMenuKey]commandMenuEntry + observations map[string]commandMenuObservation + epoch uint64 + nextGeneration uint64 + generations map[string]uint64 + reads singleflight.Group + workers chan struct{} + warmers chan struct{} + now func() time.Time } func newSessionCommandCache(source SessionCommandsClient) *sessionCommandCache { return &sessionCommandCache{source: source, entries: make(map[commandMenuKey]commandMenuEntry), - workers: make(chan struct{}, 4), warmers: make(chan struct{}, 2), now: time.Now} + generations: make(map[string]uint64), + workers: make(chan struct{}, 4), warmers: make(chan struct{}, 2), now: time.Now} } func commandMenuPath(path string) string { @@ -68,7 +71,9 @@ func (c *sessionCommandCache) scope(id, path string) string { } func browsingCommandMenu(menu SessionCommandMenu) SessionCommandMenu { - menu.Choices = append([]SessionCommandChoice(nil), menu.Choices...) + choices := make([]SessionCommandChoice, len(menu.Choices)) + copy(choices, menu.Choices) + menu.Choices = choices // Required JSON array, including empty input menus. for i := range menu.Choices { menu.Choices[i].method, menu.Choices[i].params = "", nil } @@ -94,7 +99,12 @@ func (c *sessionCommandCache) peek(id, path string) (SessionCommandMenu, bool) { } func (c *sessionCommandCache) invalidateLocked(id string) { - c.generation++ // Also prevents reads started before invalidation repopulating it. + if id == "" { + c.epoch++ + clear(c.generations) + } else { + delete(c.generations, id) + } for key := range c.entries { if id == "" || key.session == id { delete(c.entries, key) @@ -141,10 +151,17 @@ func (c *sessionCommandCache) load(ctx context.Context, id, path string, fresh b return c.source.SessionCommands(ctx, id, originalPath) } c.mu.Lock() - generation := c.generation + generation := c.generations[id] + if generation == 0 { + // Unique stamps cannot be reused after removing a session's state. + c.nextGeneration++ + generation = c.nextGeneration + c.generations[id] = generation + } + epoch := c.epoch c.mu.Unlock() key := commandMenuKey{id, path} - flight, _ := json.Marshal([]any{id, path, scope, generation}) + flight, _ := json.Marshal([]any{id, path, scope, epoch, generation}) result := c.reads.DoChan(string(flight), func() (any, error) { if !fresh { if menu, hit := c.peek(id, path); hit { @@ -187,7 +204,7 @@ func (c *sessionCommandCache) load(ctx context.Context, id, path string, fresh b } c.mu.Lock() defer c.mu.Unlock() - if c.generation != generation { + if c.epoch != epoch || c.generations[id] != generation { return nil, ErrSessionCommand } for childPath, branch := range branches { diff --git a/internal/codex/session_command_cache_test.go b/internal/codex/session_command_cache_test.go index 6a31244..b5e34ad 100644 --- a/internal/codex/session_command_cache_test.go +++ b/internal/codex/session_command_cache_test.go @@ -2,6 +2,7 @@ package codex import ( "context" + "encoding/json" "errors" "fmt" "strings" @@ -16,6 +17,7 @@ type menuCacheSource struct { epoch string fail bool help string + empty bool started chan string release chan struct{} } @@ -32,7 +34,7 @@ func (f *menuCacheSource) SessionCommands(ctx context.Context, id, path string) f.reads = map[string]int{} } f.reads[id+":"+path]++ - fail, help, epoch := f.fail, f.help, f.epoch + fail, help, epoch, empty := f.fail, f.help, f.epoch, f.empty f.mu.Unlock() if f.started != nil { f.started <- id + ":" + path @@ -47,6 +49,9 @@ func (f *menuCacheSource) SessionCommands(ctx context.Context, id, path string) if fail { return SessionCommandMenu{}, ErrSessionCommand } + if empty { + return SessionCommandMenu{Path: path, Input: true, Choices: []SessionCommandChoice{}}, nil + } return SessionCommandMenu{Path: path, Revision: epoch, Choices: []SessionCommandChoice{{ ID: "choice", Label: "Option", Help: help, Action: true, method: "thread/settings/update", params: map[string]any{"threadId": id}, @@ -93,6 +98,23 @@ func TestCommandCacheRetainsTreeAndIsolatesSessionsAndCopies(t *testing.T) { } } +func TestCommandCacheInputMenusKeepEmptyChoicesArray(t *testing.T) { + f := &menuCacheSource{epoch: "one", empty: true} + c := newSessionCommandCache(f) + for _, path := range []string{"rename", "cd"} { + for range 2 { // Initial response and Back to the cached input menu. + menu := loadMenu(t, c, "one", path) + encoded, err := json.Marshal(menu) + if err != nil || !menu.Input || !strings.Contains(string(encoded), `"choices":[]`) { + t.Fatalf("%s input menu lost its required JSON array: %s (%v)", path, encoded, err) + } + } + if f.count("one", path) != 1 { + t.Fatal("Back did not reuse the cached input menu") + } + } +} + func TestCommandCacheExpiryRefreshAndConnectionScope(t *testing.T) { f := &menuCacheSource{epoch: "first"} c := newSessionCommandCache(f) @@ -176,6 +198,85 @@ func TestCommandCacheRejectsInvalidatedInflightResult(t *testing.T) { } } +func TestCommandCacheOtherSessionChangesDoNotRejectPendingRead(t *testing.T) { + for _, change := range []string{"invalidate", "refresh", "observation"} { + t.Run(change, func(t *testing.T) { + f := &menuCacheSource{epoch: "one", started: make(chan string, 2), release: make(chan struct{})} + c := newSessionCommandCache(f) + usage := LiveUsageSnapshot{CodexUp: true, Sessions: []LiveUsageSession{{ID: "one"}, {ID: "two"}}} + c.observe(usage) + done := make(chan error, 1) + go func() { _, err := c.load(context.Background(), "one", "model", false); done <- err }() + <-f.started + var refreshed chan error + switch change { + case "refresh": + refreshed = make(chan error, 1) + go func() { _, err := c.load(context.Background(), "two", "model", true); refreshed <- err }() + <-f.started + case "observation": + usage.Sessions[1].Name = "Renamed" + c.observe(usage) + default: + c.invalidate("two") + } + close(f.release) + if err := <-done; err != nil { + t.Fatal("other session's change rejected this session's read", err) + } + if refreshed != nil { + if err := <-refreshed; err != nil { + t.Fatal(err) + } + } + if _, ok := c.peek("one", "model"); !ok { + t.Fatal("other session's change discarded this session's result") + } + }) + } +} + +func TestCommandCacheWholeInvalidationRejectsPendingRead(t *testing.T) { + f := &menuCacheSource{epoch: "one", started: make(chan string, 1), release: make(chan struct{})} + c := newSessionCommandCache(f) + done := make(chan error, 1) + go func() { _, err := c.load(context.Background(), "one", "model", false); done <- err }() + <-f.started + c.invalidate("") + close(f.release) + if err := <-done; !errors.Is(err, ErrSessionCommand) { + t.Fatal("whole-cache invalidation accepted an old read", err) + } + if _, ok := c.peek("one", "model"); ok { + t.Fatal("old read repopulated the cleared cache") + } +} + +func TestCommandCacheRefreshDoesNotJoinInvalidatedFlight(t *testing.T) { + f := &menuCacheSource{epoch: "one", started: make(chan string, 2), release: make(chan struct{})} + c := newSessionCommandCache(f) + old, fresh := make(chan error, 1), make(chan error, 1) + go func() { _, err := c.load(context.Background(), "one", "model", false); old <- err }() + <-f.started + go func() { _, err := c.load(context.Background(), "one", "model", true); fresh <- err }() + select { + case <-f.started: + case <-time.After(5 * time.Second): + close(f.release) + t.Fatal("refresh joined the invalidated read instead of starting a new one") + } + close(f.release) + if err := <-old; !errors.Is(err, ErrSessionCommand) { + t.Fatal("old read survived the session refresh", err) + } + if err := <-fresh; err != nil { + t.Fatal("fresh read was rejected", err) + } + if f.count("one", "model") != 2 { + t.Fatal("refresh did not make a new catalogue read") + } +} + func TestCommandCacheBoundsAndExcludesTypedArgumentsAndFailures(t *testing.T) { f := &menuCacheSource{epoch: "one"} c := newSessionCommandCache(f) diff --git a/internal/codex/session_commands_unix.go b/internal/codex/session_commands_unix.go index 7a621ed..9eb4450 100644 --- a/internal/codex/session_commands_unix.go +++ b/internal/codex/session_commands_unix.go @@ -29,6 +29,13 @@ type commandThread struct { } } +func (p *daemonStatusProvider) bumpCommandTransitionLocked(id string) { + if p.commandTransitions == nil { + p.commandTransitions = make(map[string]uint64) + } + p.commandTransitions[id]++ +} + func (p *daemonStatusProvider) sessionCommandScope(id, path string) string { p.mu.Lock() defer p.mu.Unlock() @@ -36,12 +43,14 @@ func (p *daemonStatusProvider) sessionCommandScope(id, path string) string { return "" } status := p.statuses[id] + transition := p.commandTransitions[id] if path == "" || path == "help" { status = sessionRuntimeUnknown // Root names/help survive ordinary turn transitions. + transition = 0 } // Resuming a thread can repeat its settings notification. Invalidate on // effective values, not notification counters, so warming can settle. - return commandDigest([]any{p.commandGeneration, status, p.threadDirectories[id], p.threadSettings[id]}) + return commandDigest([]any{p.commandGeneration, status, transition, p.threadDirectories[id], p.threadSettings[id]}) } func commandDigest(value any) string { diff --git a/internal/codex/session_commands_unix_test.go b/internal/codex/session_commands_unix_test.go index c72e011..7052343 100644 --- a/internal/codex/session_commands_unix_test.go +++ b/internal/codex/session_commands_unix_test.go @@ -103,7 +103,7 @@ func TestCachedCommandClientRevalidatesWritesAndSettingsScope(t *testing.T) { } func TestCommandDiscoveryRetainsFetchedSubmenus(t *testing.T) { - p, _ := newQuotaDaemon(t) + p, fixture := newQuotaDaemon(t) ctx := context.Background() if _, ok := p.Fetch(ctx, []string{"one"}); !ok { t.Fatal("daemon unavailable") @@ -118,16 +118,65 @@ func TestCommandDiscoveryRetainsFetchedSubmenus(t *testing.T) { if !ok || menu.Path != path || menu.Revision == "" || path == "model" && len(menu.Choices) != 1 { t.Fatalf("discovery discarded the fetched %s submenu", path) } + if menu.Input { + encoded, err := json.Marshal(menu) + if err != nil || !strings.Contains(string(encoded), `"choices":[]`) { + t.Fatalf("cached %s input menu must serialize choices as an array: %s (%v)", path, encoded, err) + } + } + } + // Do not peek while working: the original idle scope must not revive after + // a full idle -> working -> idle cycle within the cache lifetime. + for _, status := range []string{"active", "idle"} { + fixture.mu.Lock() + fixture.commandStatus = status + fixture.mu.Unlock() + p.mu.Lock() + p.lastStatusAt = time.Time{} + p.mu.Unlock() + if _, ok := p.Fetch(ctx, []string{"one"}); !ok { + t.Fatal("status refresh failed") + } } - // A running turn invalidates actionable leaves but preserves root names/help. - p.mu.Lock() - p.statuses["one"] = sessionRuntimeWorking - p.mu.Unlock() if _, ok := c.CachedSessionCommands("one", ""); !ok { t.Fatal("turn transition discarded stable root suggestions") } if _, ok := c.CachedSessionCommands("one", "model"); ok { - t.Fatal("turn transition retained an idle submenu") + t.Fatal("complete turn cycle revived the pre-turn submenu") + } +} + +type transitionMenuSource struct { + *menuCacheSource + provider *daemonStatusProvider +} + +func (f *transitionMenuSource) sessionCommandScope(id, path string) string { + return f.provider.sessionCommandScope(id, path) +} + +func TestCommandCacheRejectsReadSpanningTurnBetweenPolls(t *testing.T) { + p, _ := newQuotaDaemon(t) + if _, ok := p.Fetch(context.Background(), []string{"one"}); !ok { + t.Fatal("daemon unavailable") + } + f := &transitionMenuSource{menuCacheSource: &menuCacheSource{started: make(chan string, 1), release: make(chan struct{})}, provider: p} + c := newSessionCommandCache(f) + root := p.sessionCommandScope("one", "") + done := make(chan error, 1) + go func() { _, err := c.load(context.Background(), "one", "model", false); done <- err }() + <-f.started + p.handleNotification("turn/started", json.RawMessage(`{"threadId":"one","turn":{"id":"quick-turn"}}`)) + p.handleNotification("turn/completed", json.RawMessage(`{"threadId":"one","turn":{"id":"quick-turn","status":"completed"}}`)) + close(f.release) + if err := <-done; err != ErrSessionCommand { + t.Fatal("read spanning a complete turn was accepted", err) + } + if _, ok := c.peek("one", "model"); ok { + t.Fatal("read spanning a complete turn repopulated the cache") + } + if p.sessionCommandScope("one", "") != root { + t.Fatal("turn cycle invalidated stable root suggestions") } }