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(`EMPTY FULL
`),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(`ENTER REVIEW `),Eo=J(`C CONFIRM CHANGE `),Do=J(`ENTER REVIEW `),Oo=J(` `),ko=J(`← BACK `),Ao=J(`F REFRESH OPTIONS `),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(`ENTER REVIEW `),Eo=J(`C CONFIRM CHANGE `),Do=J(`ENTER REVIEW `),Oo=J(` `),ko=J(`← BACK `),Ao=J(`F REFRESH OPTIONS `),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.
CONFIRM SEND NOW BACK `,1),Vo=J(`SEND NOW `),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.
CONFIRM SEND NOW BACK `,1),Vo=J(`SEND NOW `),Ho=J(`
In memory only. Follows session model, reasoning and speed. Closing
Codexometer cancels pending work.
EDIT · Ctrl+S DELETE TRIGGER `),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}← BACK {/if}
{#if !suggesting} load(menu?.path || '')}>F REFRESH OPTIONS load(menu?.path || '', undefined, true)}
+ >F REFRESH OPTIONS{/if}
ESC CANCEL
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")
}
}