Skip to content

feat: interactive command modals for the OpenCode TUI#76

Open
iceteaSA wants to merge 21 commits into
cortexkit:mainfrom
iceteaSA:feat/command-modals
Open

feat: interactive command modals for the OpenCode TUI#76
iceteaSA wants to merge 21 commits into
cortexkit:mainfrom
iceteaSA:feat/command-modals

Conversation

@iceteaSA

@iceteaSA iceteaSA commented Jun 14, 2026

Copy link
Copy Markdown
Contributor

Summary

In the OpenCode TUI, the seven /claude-* slash commands now open interactive modal dialogs instead of posting a text reply. Applying a change in a modal persists it through the same configuration the slash arguments already use, so a modal and the typed command (e.g. /claude-routing fallback-first) are equivalent. Outside the OpenCode TUI (OpenCode desktop or headless) the commands print their text summary exactly as before, and Pi is unaffected (separate plugin, no TUI).

Command Modal
/claude-quota Read-only view rendering the same per-account quota bars + pacing as the sidebar
/claude-routing Select main-first / fallback-first
/claude-fast Toggle fast mode on/off
/claude-dump Toggle request-dump capture on/off
/claude-cache Select 1h cache mode (off / explicit / automatic / hybrid)
/claude-cachekeep Prompt for a keepalive window (HH-HH) or off
/claude-killswitch Enable/disable + per-account 5h,1w threshold editor

How it works

The OpenCode server process and the TUI run as separate processes, so the command hook (server side) can't open a dialog (TUI side) directly. This adds a small loopback RPC bridge:

  • A localhost-only HTTP server (bound to 127.0.0.1, ephemeral port, bearer-authenticated, body-size capped) started from the plugin's server entry.
  • A port file written atomically (temp + rename, mode 0600) under a per-project path in the OpenCode state dir, discovered by the TUI via live-pid matching. Override with OPENCODE_ANTHROPIC_AUTH_RPC_DIR.
  • An in-memory notification queue (session-scoped + global, capped, evict-oldest) with a TUI heartbeat.
  • The TUI polls the bridge (~500ms), opens the matching dialog, and applies changes back through the bridge to the existing executePersistent* setters.

The command hook pushes a dialog notification when a TUI is connected, otherwise falls back to the existing ignored-message text path; either way it aborts the command so the slash template never reaches the model. The /claude-quota modal reuses the sidebar's AccountBlock components fed by the same state file + theme, so it matches the sidebar's bars/pacing and respects the same section prefs.

Testing

  • New unit suites for the RPC transport: rpc-notifications, rpc-port-file, rpc-server (queue scoping/eviction, port-file atomicity + dead-pid pruning, bearer 401/200, oversized-body rejection, the apply route).
  • Full workspace gates green: format, lint, typecheck, build, and the full test suite — no regressions.
  • All seven dialogs were exercised live in the OpenCode TUI (open, apply, re-open to confirm persistence); the quota modal was visually confirmed against the sidebar.

Notes

  • Built and reviewed cross-family with an implement→review→fix loop; reviews caught and fixed a global-notification prune bug, an RPC body-size DoS, a stale-knob-after-mutation bug, and a quota-modal section-gating parity gap.
  • Known cosmetic: running a /claude-* command still emits a single err_… failed line in the OpenCode log, because the command hook aborts via a thrown sentinel (the existing mechanism — same as the prior text-mode commands and as other plugins do). The command works correctly; silencing that log cleanly is a possible follow-up.

View with Codesmith Autofix with Codesmith
Need help on this PR? Tag /codesmith with what you need. Autofix is disabled.


Summary by cubic

Adds interactive modals for the seven /claude-* commands in the OpenCode TUI; changes made in a modal persist the same as typed slash args. Adds a localhost, bearer‑auth RPC bridge so the server can trigger TUI dialogs; outside the TUI, commands still print text summaries.

  • New Features

    • TUI modals for: quota (read‑only), routing, fast, dump, cache (off/explicit/automatic/hybrid), cachekeep window, and killswitch (enable/disable + per‑account 5h/1w thresholds).
    • Quota modal mirrors the sidebar’s bars/pacing and respects section visibility.
    • Local RPC bridge: localhost‑only, bearer‑auth, ephemeral port + port file, 1 MB byte‑accurate body cap; override dir with OPENCODE_ANTHROPIC_AUTH_RPC_DIR.
    • TUI polls ~500ms with a per‑session notification queue; falls back to text when no TUI is connected.
    • Apply route persists changes via existing setters; dialogs and typed commands are equivalent.
    • Tests cover notifications, port‑file discovery, and RPC server (including oversized‑body rejection); build includes new src/rpc/* and dialog modules; docs updated.
  • Bug Fixes

    • Enforce the RPC request body limit by UTF‑8 byte length to block multibyte bypasses; tests added.

Written for commit 6c6ecfc. Summary will update on new commits.

Review in cubic

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

iceteaSA has reached the 50-review limit for trial accounts. To continue receiving code reviews, upgrade your plan.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 14 files

Confidence score: 3/5

  • In packages/opencode/src/rpc/rpc-server.ts, the RPC body-size guard uses string length rather than byte length, so multibyte payloads can bypass the intended limit and increase DoS exposure if merged as-is—switch the check to a byte-based calculation (for example via Buffer byte length) and add a regression test before merging.
Architecture diagram
sequenceDiagram
    participant User as TUI User
    participant TUI as OpenCode TUI
    participant TUICmd as command-dialogs.tsx
    participant RPCClient as rpc-client.ts
    participant RPCServer as rpc-server.ts
    participant Notif as notifications.ts
    participant PortFile as port-file.ts
    participant Plugin as Plugin index.ts
    participant Storage as Config Storage
    participant Sidebar as Sidebar State

    Note over User,Sidebar: Command Modal Flow (TUI connected)

    User->>TUI: Types /claude-<command>
    TUI->>Plugin: onCommand hook triggered
    Plugin->>Plugin: buildDialogPayload()
    Plugin->>Notif: isTuiConnected(sessionID)
    alt TUI is polling
        Notif-->>Plugin: true
        Plugin->>Notif: pushNotification(payload, sessionID)
        Plugin->>Plugin: cleanAbort() - abort command
    else No TUI connected
        Plugin->>Plugin: sendIgnoredMessage() - text fallback
        Plugin->>Plugin: cleanAbort() - abort command
    end

    Note over TUI,RPCClient: TUI Poll Loop (~500ms)

    TUI->>TUI: Poll interval fires
    TUI->>RPCClient: pending(lastNotificationId, sessionId)
    RPCClient->>PortFile: discoverPortFile(rpcDir)
    PortFile-->>RPCClient: { port, token, pid }
    RPCClient->>RPCServer: POST /rpc/pending-notifications (Bearer auth)
    RPCServer->>Notif: drainNotifications(lastReceivedId, sessionId)
    Notif-->>RPCServer: new notifications[]
    RPCServer-->>RPCClient: { messages: [...] }
    RPCClient-->>TUI: notifications[]

    TUI->>TUI: Sort notifications by id
    loop For each notification
        TUI->>TUICmd: openCommandDialog(api, payload, applyFn)
        alt quota command
            TUICmd->>TUI: Show read-only quota modal
            Note over TUI,Sidebar: Modal reuses AccountBlock components
            TUI->>Sidebar: readStateFromFile()
            Sidebar-->>TUI: quota data
        else routing/fast/dump/cache commands
            TUICmd->>TUI: Show selection/toggle dialog
            User->>TUICmd: Makes selection
            TUICmd->>RPCClient: apply({ command, arguments })
            RPCClient->>RPCServer: POST /rpc/apply (Bearer auth)
            RPCServer->>Plugin: apply(request)
            Plugin->>Plugin: buildDialogPayload() with args
            Plugin->>Storage: Persist config change
            Storage-->>Plugin: Confirmed
            Plugin-->>RPCServer: { text, knobs }
            RPCServer-->>RPCClient: ApplyResult
            RPCClient-->>TUICmd: result
            TUICmd->>TUI: Show toast and close dialog
        else cachekeep command
            TUICmd->>TUI: Show prompt dialog
            User->>TUICmd: Enters window value
            TUICmd->>RPCClient: apply({ command, arguments })
            Note over RPCClient,RPCServer: Same apply flow as above
            RPCClient-->>TUICmd: result
            TUICmd->>TUI: Show toast and close dialog
        else killswitch command
            TUICmd->>TUI: Show selection dialog (enable/disable/edit)
            alt edit thresholds
                User->>TUICmd: Selects edit
                TUICmd->>TUI: Show prompt dialog for thresholds
                User->>TUICmd: Enters threshold values
            else toggle
                User->>TUICmd: Selects on/off
            end
            TUICmd->>RPCClient: apply()
            Note over RPCClient,RPCServer: Same apply flow
            RPCClient-->>TUICmd: result
            TUICmd->>TUI: Show toast and close dialog
        end
    end

    Note over TUI,RPCClient: TUI Heartbeat

    loop Every ~500ms
        TUI->>RPCClient: pending(0, sessionId)
        RPCClient->>RPCServer: POST /rpc/pending-notifications
        RPCServer->>Notif: drainNotifications(0, sessionId)
        Notif->>Notif: Record drain timestamp
        Notif-->>RPCServer: []
        RPCServer-->>RPCClient: { messages: [] }
        RPCClient-->>TUI: []
        Note over Notif: Updates isTuiConnected window
    end

    Note over RPCServer,PortFile: RPC Server Lifecycle

    Plugin->>Plugin: Start RPC server on plugin init
    Plugin->>RPCServer: startRpcServer(options)
    RPCServer->>RPCServer: Generate bearer token
    RPCServer->>RPCServer: Listen on 127.0.0.1:0
    RPCServer->>PortFile: writePortFile(dir, { port, token, pid })
    PortFile-->>RPCServer: File written
    RPCServer-->>Plugin: { port, token, stop }
Loading

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread packages/opencode/src/rpc/rpc-server.ts Outdated

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

iceteaSA has reached the 50-review limit for trial accounts. To continue receiving code reviews, upgrade your plan.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant