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..7eba0a4 100644 --- a/internal/codex/daemon_status_unix.go +++ b/internal/codex/daemon_status_unix.go @@ -22,10 +22,12 @@ const ( ) type daemonStatusProvider struct { - 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 @@ -121,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() @@ -251,6 +258,8 @@ func (p *daemonStatusProvider) ensureConnected(ctx context.Context) error { return nil } 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) @@ -421,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 { @@ -536,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/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..c35eaa4 --- /dev/null +++ b/internal/codex/session_command_cache.go @@ -0,0 +1,294 @@ +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 + 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), + generations: make(map[string]uint64), + 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 { + 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 + } + 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) { + 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) + } + } +} + +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.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, epoch, 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.epoch != epoch || c.generations[id] != 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..b5e34ad --- /dev/null +++ b/internal/codex/session_command_cache_test.go @@ -0,0 +1,346 @@ +package codex + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + "sync" + "testing" + "time" +) + +type menuCacheSource struct { + mu sync.Mutex + reads map[string]int + epoch string + fail bool + help string + empty bool + 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, empty := f.fail, f.help, f.epoch, f.empty + 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 + } + 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}, + }}}, 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 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) + 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 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) + 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..9eb4450 100644 --- a/internal/codex/session_commands_unix.go +++ b/internal/codex/session_commands_unix.go @@ -29,6 +29,30 @@ 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() + if p.connection == nil { + 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, transition, p.threadDirectories[id], p.threadSettings[id]}) +} + func commandDigest(value any) string { data, _ := json.Marshal(value) return fmt.Sprintf("%x", sha256.Sum256(data)) @@ -107,6 +131,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 +158,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 +200,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 +220,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 +501,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..7052343 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" @@ -54,6 +55,131 @@ 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) + } + 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) { + p, fixture := 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) + } + 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") + } + } + if _, ok := c.CachedSessionCommands("one", ""); !ok { + t.Fatal("turn transition discarded stable root suggestions") + } + if _, ok := c.CachedSessionCommands("one", "model"); ok { + 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") + } +} + 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(`Cycle duration or reset date unavailable — position cannot be plotted.
`),Ka=J(`
`),Xa=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(`
Review expired. Press Enter to review again.
`),yo=J(``,1),bo=J(`
`,1),xo=J(`
No available matching commands.
`),So=J(` `,1),Co=J(``),wo=J(`
Review expired. Review the option again.
`),To=J(``),Eo=J(``),Do=J(``),Oo=J(``),ko=J(``),Ao=J(``),jo=J(``),Mo=J(``,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(`
Review expired. Press Enter to review again.
`),yo=J(``,1),bo=J(`
`,1),xo=J(`
No available matching commands.
`),So=J(` `,1),Co=J(``),wo=J(`
Review expired. Review the option again.
`),To=J(``),Eo=J(``),Do=J(``),Oo=J(``),ko=J(``),Ao=J(``),jo=J(``),Mo=J(` `),Bo=J(`
Send this saved prompt now? This starts Codex work and consumes quota.
`,1),Vo=J(``),Ho=J(`
In memory only. Follows session model, reasoning and speed. Closing + may later supersede model settings.`))};Z(i,e=>{K(o)?e(a):K(c)?e(s,1):e(l,-1)}),P(n),U(()=>X(r,`TARGET // ${t.session??``}. `)),Y(e,n)};Z(s,e=>{K(y).action&&e(c)}),P(n),Ii(n,e=>R(te,e),()=>K(te)),U(()=>{X(i,K(y).label),X(o,K(y).help||`No additional help supplied by Codex.`)}),Y(e,n)},w=e=>{var t=So(),i=B(t),a=e=>{var t=go(),i=z(t);r(i),P(t),Ii(t,e=>R(D,e),()=>K(D)),U(()=>Q(t,`aria-activedescendant`,`${n}-${K(T)}`)),Y(e,t)},o=e=>{var t=_o();let n;var i=z(t);r(i),P(t),Ii(t,e=>R(D,e),()=>K(D)),U(()=>n=ci(t,1,`command-options svelte-7bfwr9`,null,n,{vertical:K(ge)})),Y(e,t)};Z(i,e=>{K(A)?e(a):e(o,-1)});var s=H(i,2),c=e=>{var t=bo(),n=B(t),r=V(n,!0),i=H(n,2),a=e=>{var t=yo(),n=B(t),r=V(n),i=H(n,2),a=e=>{Y(e,vo())};Z(i,e=>{K(b)&&K(S)>=K(x)&&e(a)}),U(()=>X(r,`REVIEW // ${K(me).label??``}. No change sent yet.`)),Y(e,t)};Z(i,e=>{K(me)&&e(a)}),U(()=>X(r,K(fe)[K(T)]?.help)),Y(e,t)};Z(s,e=>{K(A)&&e(c)});var l=H(s,2),u=e=>{Y(e,xo())};Z(l,e=>{!K(g)&&!K(fe).length&&e(u)}),Y(e,t)};Z(p,e=>{K(v)?.input?e(m):K(y)?e(h,1):e(w,-1)});var E=H(p,2),O=e=>{var t=Co(),n=V(t,!0);U(()=>X(n,K(j).unavailable)),Y(e,t)};Z(E,e=>{K(j)?.unavailable&&e(O)});var k=H(E,2),ne=e=>{Y(e,wo())};Z(k,e=>{K(b)&&K(S)>=K(x)&&!K(A)&&e(ne)});var ie=H(k,2),ae=z(ie),ce=e=>{var t=Nr(),r=B(t),i=e=>{var t=To();U(e=>{Q(t,`form`,`${n}-input`),t.disabled=e},[()=>K(g)||K(he)||!K(C).trim()]),Y(e,t)},o=e=>{var t=Nr(),n=B(t),r=e=>{var t=Eo();U(()=>t.disabled=K(g)||K(he)&&!a()),q(`click`,t,xe),Y(e,t)},i=e=>{var t=Do();U(()=>t.disabled=K(g)||K(he)&&!a()),q(`click`,t,()=>{K(j)&&be(K(j))}),Y(e,t)};Z(n,e=>{K(pe)?e(r):e(i,-1)}),Y(e,t)},s=e=>{var t=Oo(),n=V(t);U(()=>{t.disabled=K(g)||K(he)&&!a(),X(n,`ENTER ${K(j).next?`OPEN`:`HELP`}`)}),q(`click`,t,()=>{K(j)&&ye(K(j))}),Y(e,t)};Z(r,e=>{K(v)?.input?e(i):K(j)?.action&&!K(j).unavailable?e(o,1):!K(y)&&K(j)&&!K(j).unavailable&&e(s,2)}),Y(e,t)};Z(ae,e=>{K(_)||e(ce)});var le=H(ae,2),ue=e=>{var t=ko();U(()=>t.disabled=K(g)),q(`click`,t,oe),Y(e,t)};Z(le,e=>{K(re)&&e(ue)});var de=H(le,2),_e=e=>{var t=Ao();U(()=>t.disabled=K(g)),q(`click`,t,()=>N(K(v)?.path||``,void 0,!0)),Y(e,t)};Z(de,e=>{K(ge)||e(_e)});var Se=H(de,2);P(ie);var Ce=H(ie,2),we=e=>{var t=jo(),n=V(t);U(()=>X(n,`↑/↓ select${K(ge)?` · Tab complete`:``}`)),Y(e,t)};Z(Ce,e=>{!K(v)?.input&&!K(y)&&e(we)}),P(i),Ii(i,e=>R(ee,e),()=>K(ee)),U(()=>{Q(i,`aria-label`,K(ge)?`Slash command suggestions`:`Session command options`),o=ci(i,1,`svelte-7bfwr9`,null,o,{"command-popup":K(ge)})}),q(`keydown`,i,ve),q(`click`,Se,M),Y(e,i)};return Z(De,e=>{K(h)&&e(Oe)}),P(Ce),U(()=>we.disabled=K(he)&&!a()||K(g)),q(`click`,we,()=>{R(h,!K(h)),R(E,K(h)?null:i(),!0),R(b,``),K(h)?(R(k,[],!0),N(``)):M()}),Y(e,Ce),We(Se)}Sr([`click`,`keydown`]);var Fo=J(` `,1),Io=J(`
`),Bo=J(`
Send this saved prompt now? This starts Codex work and consumes quota.
`,1),Vo=J(``),Ho=J(`
In memory only. Follows session model, reasoning and speed. Closing Codexometer cancels pending work.
MODEL / REASONING LEVEL / SPEED
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(`
Controls require a supported live request from a connected shared app-server session. Local observations alone cannot provide them.
`),$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 @@