From 25ddecef6b7d2f0de1bdcb06fd41e015d07eba1f Mon Sep 17 00:00:00 2001 From: merefield Date: Sun, 6 Sep 2026 13:41:53 +0100 Subject: [PATCH 1/9] FEAT: add Monitor context previews and confirmed command approvals --- README.md | 166 ++++++++-- internal/codex/daemon_status.go | 1 + internal/codex/daemon_status_unix.go | 10 + internal/codex/daemon_status_unix_test.go | 5 +- internal/codex/live_usage.go | 29 +- internal/codex/session_approval.go | 30 ++ internal/codex/session_approval_test.go | 68 ++++ internal/codex/session_approval_unix.go | 55 ++++ internal/codex/session_approval_unix_test.go | 101 ++++++ internal/codex/session_context.go | 232 ++++++++++++++ internal/codex/session_context_daemon.go | 197 ++++++++++++ internal/codex/session_context_test.go | 147 +++++++++ internal/i18n/locales/de.json | 16 + internal/i18n/locales/en-GB.json | 16 + internal/i18n/locales/es.json | 16 + internal/i18n/locales/fr.json | 16 + internal/i18n/locales/it.json | 16 + internal/i18n/locales/ja.json | 16 + internal/i18n/locales/nl.json | 16 + internal/i18n/locales/ru.json | 16 + internal/i18n/locales/zh-Hans.json | 16 + internal/ui/localisation_test.go | 3 + internal/ui/model.go | 47 +++ internal/ui/monitor.go | 33 +- internal/ui/monitor_approval.go | 71 +++++ internal/ui/monitor_approval_test.go | 115 +++++++ internal/ui/monitor_context.go | 316 +++++++++++++++++++ internal/ui/monitor_context_test.go | 163 ++++++++++ internal/ui/preferences.go | 19 +- intro-post.md | 4 +- main.go | 56 +++- main_test.go | 25 ++ 32 files changed, 1982 insertions(+), 55 deletions(-) create mode 100644 internal/codex/session_approval.go create mode 100644 internal/codex/session_approval_test.go create mode 100644 internal/codex/session_approval_unix.go create mode 100644 internal/codex/session_approval_unix_test.go create mode 100644 internal/codex/session_context.go create mode 100644 internal/codex/session_context_daemon.go create mode 100644 internal/codex/session_context_test.go create mode 100644 internal/ui/monitor_approval.go create mode 100644 internal/ui/monitor_approval_test.go create mode 100644 internal/ui/monitor_context.go create mode 100644 internal/ui/monitor_context_test.go diff --git a/README.md b/README.md index 78c5f1c..2e36190 100644 --- a/README.md +++ b/README.md @@ -302,12 +302,16 @@ codexometer --demo ## Recommended Codex CLI setup Codexometer works with an ordinary Codex CLI process, but connecting your CLI -sessions through one shared app-server daemon unlocks its most accurate live +sessions through one shared app-server unlocks its most accurate live telemetry on macOS, Linux, and WSL: - **Definite attention states** — Monitor can distinguish `INPUT NEEDED` from `APPROVAL NEEDED` using live per-thread status instead of eventually showing the cautious `CHECK SESSION` inactivity fallback. +- **Command approval details and controls** — supported live requests show the + command, directory and reason in Monitor's `[i]` detail page, with + **Approve once → Confirm approval** and **Decline** buttons. Incomplete or + unsupported requests still need answering in Codex. - **Resolved-model API equivalents** — live model-reroute and response-usage events let Codexometer price a positively matched call using the model that actually served it, rather than relying only on the requested model saved in @@ -315,16 +319,16 @@ telemetry on macOS, Linux, and WSL: - **Better multi-session visibility** — every connected CLI tab or pane remains a separate Monitor session while sharing the same accurate status source; explicitly linked subagents are still folded into their root session. -- **No extra Codexometer authentication** — the daemon, CLI clients, and +- **No extra Codexometer authentication** — the server, CLI clients, and Codexometer continue to use the prevailing Codex login under the same `CODEX_HOME`. -- **Safe degradation** — sessions not connected to the daemon continue to use +- **Safe degradation** — sessions not connected to the server continue to use all core quota features with requested-model pricing, rollout lifecycle signals, and writer-lock attention detection. Set up the recommended arrangement as follows. -1. Confirm that the current standalone Codex CLI and Codexometer see the same +1. Confirm that the current Codex CLI and Codexometer see the same login and `CODEX_HOME`: ```sh @@ -332,14 +336,40 @@ Set up the recommended arrangement as follows. codexometer --check-auth ``` -2. Start the managed daemon and confirm that it is ready: +2. Choose **one** way to start the shared server. Both use the default Unix + socket that Codexometer detects; do not run both against that socket. + + **Option A: use your existing installation (no reinstall).** In a dedicated + terminal, run: + + ```sh + codex app-server --listen unix:// + ``` + + Leave that terminal and server running while using connected sessions. + This runs in the foreground; it does not need the managed standalone install. + + **Option B: managed background daemon.** This requires the installer-managed + standalone binary at `$CODEX_HOME/packages/standalone/current/codex` + (normally `~/.codex/packages/standalone/current/codex`). An npm, Homebrew or + manually built executable alone does not satisfy that requirement. If you + want this option, install the managed binary first: + + ```sh + curl -fsSL https://chatgpt.com/codex/install.sh | sh + ``` + + Then start the daemon and confirm that it is ready: ```sh codex app-server daemon start codex app-server daemon version ``` -3. Launch each working Codex CLI terminal against the daemon's default Unix + If you see **managed standalone Codex install not found**, either complete + that installation or use Option A instead. + +3. Launch each working Codex CLI terminal against the server's default Unix control socket: ```sh @@ -347,7 +377,14 @@ Set up the recommended arrangement as follows. ``` Run this client command separately in every terminal tab or pane that - Codexometer should monitor through the shared daemon. + Codexometer should monitor through the shared server. Use `/resume` inside + each connected CLI to reopen an existing conversation. + + Starting the server does not interrupt or migrate existing ordinary CLI + sessions. To move a conversation, let its current task finish, exit that + ordinary CLI, then resume it through the connected client. Avoid opening + the same conversation in both modes at once. Unmigrated sessions remain + available to Codexometer through local observation, without live approvals. 4. Start Codexometer in an adjacent window or split pane before beginning work that you want attributed: @@ -360,20 +397,32 @@ Set up the recommended arrangement as follows. reconstructed after the fact. No additional Codexometer option is required; it automatically probes the same default socket under `CODEX_HOME`. + If the latest Codexometer build is already running, leave it open: it retries + the connection and discovers newly connected sessions automatically. Allow + a refresh cycle. Restart it if you have replaced its binary or changed its + `CODEX_HOME`. A context detail marked **LIVE** came from the shared server; + **LOCAL** means that excerpt came from rollout logs, which do not persist + the live approval-request events. An older local excerpt can remain until + new live context arrives. + 5. Leave Codexometer running while you work. The Monitor starts automatically; use Reset when you want a fresh measured interval, and keep unrelated Codex activity quiet while running Benchmarks if you want the cleanest comparisons. Codexometer subscribes only to thread IDs that are already loaded by the -daemon; it does not load unrelated historical sessions. +shared server; it does not load unrelated historical sessions. -Manage or stop the daemon with: +For Option B, manage or stop the daemon with: ```sh codex app-server daemon restart codex app-server daemon stop ``` +For Option A, stop the foreground server with `Ctrl+C` in its dedicated +terminal. Stopping or restarting either server disconnects its clients and +can interrupt their work; finish active tasks first. + The managed daemon lifecycle is currently experimental, Unix-only, and expects the standalone Codex installation. Native Windows and other ordinary CLI sessions remain fully usable through the fallback described above. The @@ -385,7 +434,7 @@ codex --remote ws://127.0.0.1:4500 ``` Plain WebSockets should be used only on localhost or through an SSH tunnel. -Codexometer currently auto-detects only the default Unix daemon socket, so +Codexometer currently auto-detects only the default Unix control socket, so WebSocket-connected sessions use its fallback attention detection for now. See the official [Codex app-server documentation](https://developers.openai.com/codex/app-server) for custom socket paths, secure remote connections, and authentication. @@ -417,11 +466,17 @@ ID, source classification, working directory, and the inherited-history boundary. It also reads lifecycle event names and blocking flags to identify an explicit unresolved input or approval request. To distinguish an open CLI waiting at its prompt from a closed historical session, it inspects the lock -state—not the contents—of Codex's per-thread writer lock. Message text—including -the final response carried beside timing metadata—reasoning, commands, tool -results, and credentials are ignored and never retained from ordinary Codex -sessions. The sole content-reading exception is a benchmark turn explicitly -started by Codexometer. Its Codexometer-authored policy and prompt, visible +state—not the contents—of Codex's per-thread writer lock. Monitor also extracts +bounded assistant replies/commentary, input questions and choices, approval +reasons, and command descriptions for its session-context previews. Excerpts +stay in process memory, never in preferences or a summarisation service. User +prompts, reasoning and arbitrary tool results are not retained by that reader. +Text may contain sensitive material the assistant already displayed: terminal +sanitisation is not secret redaction. Press `h` in Monitor to hide previews and +close the detail view; this saved preference controls display, not collection. + +Benchmark turns explicitly started by Codexometer have separate content capture. +Their Codexometer-authored policy and prompt, visible structured response, and—only for DigBench—sanitized game-tool requests and responses are kept in bounded process memory for the Benchmark run detail view. Reasoning events, platform instructions, credentials, request headers, @@ -430,10 +485,13 @@ or response IDs are not captured. When the managed shared daemon is available, Codexometer also keeps a local app-server subscription for already-loaded thread IDs. From that stream it -retains only runtime status flags and content-free model-reroute/token-usage -correlations needed for the features above. It ignores prompts, responses, -reasoning, tool payloads, and server requests, and never sends a turn or an -approval response through this connection. +retains runtime flags, model-reroute/token-usage correlations, and bounded +context from assistant messages and input/approval requests. Resolved requests +and new turns clear stale pending context. This connection never starts a turn +or answers a question. It can send an explicit, user-confirmed one-time command +approval or decline from the Monitor detail page (see below). Without a shared daemon, previews use +available local rollout text rather than fetching full thread histories. +Missing request details are left unavailable; there is no extra model call. If you use a nonstandard Codex executable, pass it explicitly: @@ -452,6 +510,8 @@ codexometer --codex /path/to/codex | `v` | Cycle the active Quota view | | `s` | Reset the Monitor baseline, or open Benchmark Scope | | `p` | Pause or resume live monitoring (Monitor view only) | +| `h` | Hide/show Monitor context previews and close any open context detail | +| `i` | Open context for the selected Monitor row (or first visible row), or close context detail | | `b` | Run the selected benchmark scope (Benchmark view only) | | `a` | Arm, then confirm, Run All (Benchmark view only) | | `x` | Dismiss the selected Monitor row; close Benchmark detail/Scope, or stop an active suite and retain its incomplete trial | @@ -462,12 +522,12 @@ codexometer --codex /path/to/codex | `f` | Show all, passed, or failed benchmark results | | `w` | Select Weekly in Usage, or cycle Cost, Balanced, and Speed benchmark ranking weights | | `Up` / `Down` | Select a Monitor session row or Benchmark row, or scroll open Benchmark detail | -| `Enter` / `Space` | Open a selected result, or toggle a Benchmark Scope checkbox | +| `Enter` / `Space` | Open a selected Benchmark result, or toggle a Scope checkbox; `Enter` opens/closes Monitor context | | `c` | Select Cumulative in Usage, copy the Benchmark result matrix as Markdown, or copy the complete open run detail | | `l` | Clear accumulated Benchmark results while no suite is running | | `Page Up` / `Page Down` | Page Usage history, Monitor session rows, or Benchmark results | | `q` | Quit | -| `Esc` | Cancel quota-reset confirmation/dismiss its notice, return from Benchmark detail or Scope; otherwise quit | +| `Esc` | Close Monitor context, cancel quota-reset confirmation/dismiss its notice, return from Benchmark detail or Scope; otherwise quit | | `Ctrl+C` | Quit | The responsive top rail below the account status selects Quota, Monitor, Usage, or @@ -880,8 +940,8 @@ status. A held writer lock plus a completed turn reliably identifies an open CLI waiting at its prompt. Without a shared server, three minutes without any new rollout-file activity produces only `CHECK SESSION`, because persisted data cannot distinguish an approval wait from every long-running local tool. The -Monitor does not retain the input question, approval text, response, or -conversation content. A linked child's attention state is folded into its root +context preview does not infer attention from prose. A linked child's attention +state is folded into its root so one remote Monitor row identifies the CLI session that needs intervention. A definite approval signal takes precedence, then definite input, then the inferred check state when linked members have mixed states. Because `CHECK @@ -920,10 +980,66 @@ account observation brackets each monitored segment. These operations are not atomic, so unrelated account activity during either short boundary read remains another source of uncertainty. +#### Session context previews + +Previews are visible by default. Wide terminals (at least 100 columns of usable +Monitor space) show a third box between session metrics and the graph. It holds +at most two text rows plus the source session and age when height permits. +On narrower terminals, a last reply or pending question/request takes the graph +column temporarily. Ordinary busy-session commentary stays collapsed to an +`[i]` action on the graph; `CHECK SESSION` can expose its last known activity. +With no context available, the original metrics/graph layout remains. + +- **LAST REPLY** is the last completed assistant reply, not a new question. A + local completed-turn prompt with a final reply is labelled **TURN COMPLETE**. +- **QUESTION** contains an observed blocking input request and any choices. +- **REQUEST** contains an observed approval reason/command when available. +- **LAST ACTIVITY** is observed commentary or a command, not proof that input + is required. Ages describe the last observed event; paused readings can be stale. + +Click `[i]`, or select a row with arrows and press `i`/`Enter`, for a scrollable +detail view. Use arrows, Page Up/Down, or the mouse wheel; close with `[×]`, +`Esc`, `i`, `Enter`, or `x`. Answer questions in Codex itself. +Outstanding requests take priority over ordinary activity when linked agents +share a root row, and the source ID identifies the actual member. + +**Command approvals (shared app-server only):** the detail page shows the reason, +command and working directory. When the approval event omits the command or +directory, Codexometer associates it with the preceding command item from the +same thread, turn and item. A complete ordinary command request offers clickable +**APPROVE ONCE** and **DECLINE** buttons. Approval requires a second click on +**CONFIRM APPROVAL**; closing the detail page cancels that confirmation. There is +no approval keyboard shortcut and no persistent/session-wide permission grant. +Controls are hidden on terminals too small to fit the confirmation labels. + +Actions are bound to a single pending request on the current connection. They +become unavailable when resolved elsewhere, the turn ends, or the connection +closes. The server arbitrates simultaneous responses from multiple clients. +Sending a decision is not proof that the command ran: check Codex for the outcome. +Failed or ambiguous sends are not automatically retried. + +Local rollout logs do **not** persist Codex's approval-request events, so a local +preview can show only the message preceding an approval. `INPUT NEEDED` or +`CHECK SESSION` alone never enables these controls. Requests with missing, +truncated or sanitised-away details, network approvals, file changes, permission +grants and other unsupported requests remain **REPLY IN CODEX**. The complete +eligible request is available in the scrollable detail, not just the compact +two-line preview. Unsupported requests may have only a bounded excerpt. + +The readout's `[H:HIDE]`/`[H:SHOW]` button or `h` toggles all previews; the choice +survives restarts. No excerpt is saved. Each retained excerpt is capped at 4,096 +Unicode characters; startup reads only a bounded 256 KiB rollout tail, so older +context can be unavailable. Terminal escapes and control/bidirectional-formatting +characters are stripped. Previews remain in their original language and are not +LLM-generated summaries. `--demo` includes example context and a simulated +one-time command approval without accessing a real conversation or executing +any command; restart the demo to reset its approval. + ### Saved presentation preferences -Codexometer stores only the selected theme, Quota view, benchmark filter, and -benchmark ranking weight. No quota estimate or snapshot, raw session telemetry, +Codexometer stores only the selected theme, Quota view, benchmark filter, +benchmark ranking weight, and the Monitor context hide/show preference. +No quota estimate or snapshot, raw session telemetry, benchmark result, message content, credential, session ID, email, account fingerprint, or account ID is written. The small JSON file uses the platform-standard user configuration directory: diff --git a/internal/codex/daemon_status.go b/internal/codex/daemon_status.go index 2e5c1b3..4d107f6 100644 --- a/internal/codex/daemon_status.go +++ b/internal/codex/daemon_status.go @@ -17,6 +17,7 @@ type sessionStatusProvider interface { } type sessionDaemonSnapshot struct { + Contexts map[string]SessionContext Statuses map[string]sessionRuntimeStatus ModelObservations []resolvedModelObservation SubscribedThreads map[string]struct{} diff --git a/internal/codex/daemon_status_unix.go b/internal/codex/daemon_status_unix.go index 08f821f..be917da 100644 --- a/internal/codex/daemon_status_unix.go +++ b/internal/codex/daemon_status_unix.go @@ -22,6 +22,7 @@ const ( ) type daemonStatusProvider struct { + contexts map[string]*daemonContextState socketPath string mu sync.Mutex @@ -134,6 +135,7 @@ func (p *daemonStatusProvider) statusSnapshotLocked(threadIDs []string) sessionD subscribed[threadID] = struct{}{} } return sessionDaemonSnapshot{ + Contexts: daemonContextSnapshot(p.contexts, threadIDs, statuses), Statuses: statuses, ModelObservations: append([]resolvedModelObservation(nil), p.observations...), SubscribedThreads: subscribed, @@ -188,6 +190,7 @@ func (p *daemonStatusProvider) unsubscribeMissing(ctx context.Context, threadIDs } p.mu.Lock() delete(p.subscribed, threadID) + delete(p.contexts, threadID) for key := range p.reroutedTurns { if key.threadID == threadID { delete(p.reroutedTurns, key) @@ -365,6 +368,12 @@ func (p *daemonStatusProvider) readLoop(connection *websocket.Conn) { } continue } + p.mu.Lock() + if p.contexts == nil { + p.contexts = map[string]*daemonContextState{} + } + daemonContextEvent(p.contexts, envelope.Method, envelope.ID, envelope.Params, time.Now()) + p.mu.Unlock() p.handleNotification(envelope.Method, envelope.Params) } } @@ -439,6 +448,7 @@ func (p *daemonStatusProvider) disconnect(connection *websocket.Conn) { } } p.subscribed = nil + p.contexts = nil p.reroutedTurns = nil p.lastStatusAt = time.Time{} p.statusThreads = nil diff --git a/internal/codex/daemon_status_unix_test.go b/internal/codex/daemon_status_unix_test.go index 528c566..cdd4e6a 100644 --- a/internal/codex/daemon_status_unix_test.go +++ b/internal/codex/daemon_status_unix_test.go @@ -82,7 +82,7 @@ func TestDaemonStatusProviderReadsExactThreadStates(t *testing.T) { }}) if threadID == "working" { _ = connection.WriteJSON(map[string]any{ - "id": "approval-1", "method": "item/commandExecution/requestApproval", "params": map[string]any{}, + "id": "approval-1", "method": "item/commandExecution/requestApproval", "params": map[string]any{"threadId": "approval", "reason": "Allow push?", "command": "git push"}, }) _ = connection.WriteJSON(map[string]any{"method": "model/rerouted", "params": map[string]any{ "threadId": threadID, "turnId": "turn-1", "fromModel": "gpt-5.6-sol", "toModel": "gpt-5.6-terra", @@ -129,6 +129,9 @@ func TestDaemonStatusProviderReadsExactThreadStates(t *testing.T) { if len(snapshot.ModelObservations) != 1 { t.Fatalf("reroute observations = %#v", snapshot.ModelObservations) } + if c := snapshot.Contexts["approval"]; c.Kind != SessionContextApproval || c.Text != "Allow push?\nCommand: git push\nDirectory:" || c.ApprovalToken != "" { + t.Fatalf("live request context = %+v", c) + } observation := snapshot.ModelObservations[0] if observation.ThreadID != "working" || observation.TurnID != "turn-1" || observation.Model != "gpt-5.6-terra" || observation.Usage.TotalTokens != 150 || observation.CumulativeTotal != 150 { diff --git a/internal/codex/live_usage.go b/internal/codex/live_usage.go index ba0e793..c3ff549 100644 --- a/internal/codex/live_usage.go +++ b/internal/codex/live_usage.go @@ -53,6 +53,7 @@ type LiveUsageSession struct { AgentCount int Active bool Attention SessionAttention + Context SessionContext Unattributed bool ModelCalls []LiveModelCall TurnTimings []LiveTurnTiming @@ -93,8 +94,10 @@ type LiveTurnTiming struct { } // LiveUsageReader incrementally observes token telemetry written by local Codex -// sessions. It never interprets message, reasoning, command, or tool contents. +// sessions. It also extracts bounded display-only replies and request context; +// reasoning and arbitrary tool output are never retained. type LiveUsageReader struct { + daemonContexts map[string]SessionContext SessionsRoot string WriterLocksRoot string statusProvider sessionStatusProvider @@ -118,6 +121,7 @@ type LiveUsageReader struct { } type rolloutCursor struct { + preview SessionContext offset int64 totalTokens int64 observedTokens int64 @@ -315,12 +319,14 @@ func (r *LiveUsageReader) fetchTokenUsage(ctx context.Context, forceFullDiscover } exactStatuses := map[string]sessionRuntimeStatus(nil) + r.daemonContexts = nil appServerUp := false r.daemonSubscribedThreads = nil if r.statusProvider != nil { if daemonSnapshot, exact := r.statusProvider.Fetch(ctx, r.observedThreadIDs(now)); exact { appServerUp = true exactStatuses = daemonSnapshot.Statuses + r.daemonContexts = daemonSnapshot.Contexts r.ingestResolvedModelObservations(daemonSnapshot.ModelObservations) r.daemonSubscribedThreads = daemonSnapshot.SubscribedThreads } @@ -489,6 +495,7 @@ func (r *LiveUsageReader) addFile(path string, info os.FileInfo) error { return err } cursor.attention = attention + cursor.preview = latestSessionContext(path, cursor) } if !r.initialized || !rolloutCreatedAfter(path, r.startedAt) { model, modelErr := latestRolloutModel(path, cursor) @@ -540,6 +547,7 @@ func (r *LiveUsageReader) consume(path string, cursor *rolloutCursor) error { return attentionErr } cursor.attention = attention + cursor.preview = latestSessionContext(path, cursor) return nil } if info.Size() == cursor.offset { @@ -553,6 +561,9 @@ func (r *LiveUsageReader) consume(path string, cursor *rolloutCursor) error { line, readErr := reader.ReadBytes('\n') if len(line) > 0 && line[len(line)-1] == '\n' { cursor.offset += int64(len(line)) + if preview, ok := rolloutContextRecord(line, cursor); ok { + cursor.preview = preview + } if turnID, ordinal, at, ok := rolloutTurnIDRecord(line); ok && tokenRecordIsOwned(ordinal, cursor.subagentHistoryStartOrdinal, at, cursor.nonRoot, cursor.startedAt) { cursor.currentTurnID = turnID @@ -1134,6 +1145,22 @@ func (r *LiveUsageReader) sessionSnapshots(now time.Time, liveWriters map[string group.TurnTimings = append(group.TurnTimings, cursor.turnTimings...) group.Active = group.Active || active || exactWorking || attention != SessionAttentionNone group.Attention = mergeSessionAttention(group.Attention, attention) + preview := cursor.preview + if exact && preview.pending() { + preview = SessionContext{} + } + if live, ok := r.daemonContexts[cursor.threadID]; ok && (live.At.After(preview.At) || live.pending()) { + preview = live + } + // A persisted request is not proof that it is still outstanding. Match + // it to current attention before displaying it as actionable context. + if preview.Kind == SessionContextApproval && attention != SessionAttentionApproval || + preview.Kind == SessionContextQuestion && attention != SessionAttentionInput { + preview = SessionContext{} + } + if !unattributed { + group.Context = preferSessionContext(group.Context, preview) + } groupWorking[rootID] = groupWorking[rootID] || exactWorking || localWorking // CHECK SESSION is only an inactivity inference. A freshly writing // member—or an authoritatively working daemon thread—means the grouped diff --git a/internal/codex/session_approval.go b/internal/codex/session_approval.go new file mode 100644 index 0000000..a2631f5 --- /dev/null +++ b/internal/codex/session_approval.go @@ -0,0 +1,30 @@ +package codex + +import ( + "context" + "errors" +) + +// SessionApprovalClient is deliberately separate from telemetry. Only live, +// fully displayed, ordinary command requests expose a one-use capability. +type SessionApprovalClient interface { + SessionApprovalPending(string) bool + RespondSessionApproval(context.Context, string, string) error +} + +func (c Client) SessionApprovalPending(token string) bool { + if c.LiveUsage == nil { + return false + } + p, ok := c.LiveUsage.statusProvider.(SessionApprovalClient) + return ok && p.SessionApprovalPending(token) +} + +func (c Client) RespondSessionApproval(ctx context.Context, token, decision string) error { + if c.LiveUsage != nil { + if p, ok := c.LiveUsage.statusProvider.(SessionApprovalClient); ok { + return p.RespondSessionApproval(ctx, token, decision) + } + } + return errors.New("approval unavailable; respond in Codex") +} diff --git a/internal/codex/session_approval_test.go b/internal/codex/session_approval_test.go new file mode 100644 index 0000000..4bae6ef --- /dev/null +++ b/internal/codex/session_approval_test.go @@ -0,0 +1,68 @@ +package codex + +import ( + "encoding/json" + "strings" + "testing" + "time" +) + +func approvalFixture(t *testing.T, overrides map[string]any) (map[string]*daemonContextState, SessionContext) { + t.Helper() + states := map[string]*daemonContextState{} + daemonContextEvent(states, "item/started", nil, json.RawMessage(`{"threadId":"root","turnId":"turn","item":{"id":"cmd","type":"commandExecution","command":"git push","cwd":"/work"}}`), time.Now()) + p := map[string]any{"threadId": "root", "turnId": "turn", "itemId": "cmd", "reason": "Publish branch?"} + for k, v := range overrides { + p[k] = v + } + raw, err := json.Marshal(p) + if err != nil { + t.Fatal(err) + } + daemonContextEvent(states, "item/commandExecution/requestApproval", json.RawMessage(`"req"`), raw, time.Now()) + return states, states["root"].requests[`"req"`] +} + +func TestApprovalCompleteCommandAndFailClosed(t *testing.T) { + _, c := approvalFixture(t, nil) + if c.ApprovalToken == "" || !strings.Contains(c.Text, "Command: git push\nDirectory: /work") { + t.Fatalf("missing correlated detail: %+v", c) + } + for name, fields := range map[string]map[string]any{ + "wrong turn": {"turnId": "other"}, + "wrong item": {"itemId": "other"}, + "truncated": {"command": strings.Repeat("x", 5000)}, + "controls": {"command": "echo \x1b[31mhidden"}, + "hidden directory suffix": {"cwd": "/work\t"}, + "bidi": {"command": "echo \u202Ehidden"}, + "network": {"networkApprovalContext": map[string]string{"host": "example.com", "protocol": "https"}}, + "permissions": {"additionalPermissions": map[string]any{"network": true}}, + "session only": {"availableDecisions": []string{"acceptForSession", "decline"}}, + "no decisions": {"availableDecisions": []string{}}, + "ambiguous argv": {"command": []string{"echo", "a b"}}, + } { + t.Run(name, func(t *testing.T) { + _, c := approvalFixture(t, fields) + if c.ApprovalToken != "" { + t.Fatalf("unsafe request actionable: %+v", c) + } + }) + } +} + +func TestApprovalLifecycleAndReplay(t *testing.T) { + for _, method := range []string{"serverRequest/resolved", "turn/started", "turn/completed", "turn/interrupted", "thread/closed", "item/completed"} { + t.Run(method, func(t *testing.T) { + states, _ := approvalFixture(t, nil) + daemonContextEvent(states, method, nil, json.RawMessage(`{"threadId":"root","turnId":"turn","requestId":"req","item":{"id":"cmd","type":"commandExecution"}}`), time.Now()) + if s := states["root"]; s != nil && len(s.requests) != 0 { + t.Fatal("pending approval survived lifecycle completion") + } + }) + } + states, old := approvalFixture(t, nil) + daemonContextEvent(states, "item/commandExecution/requestApproval", json.RawMessage(`"req"`), json.RawMessage(`{"threadId":"root","turnId":"turn","itemId":"cmd","command":"git status","cwd":"/work"}`), time.Now()) + if next := states["root"].requests[`"req"`]; next.ApprovalToken == old.ApprovalToken || next.ApprovalToken == "" { + t.Fatal("replay reused old capability") + } +} diff --git a/internal/codex/session_approval_unix.go b/internal/codex/session_approval_unix.go new file mode 100644 index 0000000..cdcf83c --- /dev/null +++ b/internal/codex/session_approval_unix.go @@ -0,0 +1,55 @@ +//go:build unix + +package codex + +import ( + "context" + "encoding/json" + "errors" +) + +// Caller holds mu. Tokens are regenerated on replay/reconnect; stale UI state +// cannot answer a reused JSON-RPC id on another connection. +func (p *daemonStatusProvider) approvalLocked(token string) (*daemonContextState, string) { + if token == "" || p.connection == nil { + return nil, "" + } + for _, state := range p.contexts { + for id, c := range state.requests { + if c.ApprovalToken == token { + return state, id + } + } + } + return nil, "" +} + +func (p *daemonStatusProvider) SessionApprovalPending(token string) bool { + p.mu.Lock() + defer p.mu.Unlock() + s, _ := p.approvalLocked(token) + return s != nil +} + +func (p *daemonStatusProvider) RespondSessionApproval(ctx context.Context, token, decision string) error { + if decision != "accept" && decision != "decline" { + return errors.New("unsupported approval decision") + } + p.mu.Lock() + if err := ctx.Err(); err != nil { + p.mu.Unlock() + return err + } + s, id := p.approvalLocked(token) + if s == nil { + p.mu.Unlock() + return errors.New("request expired or connection closed; check Codex") + } + // Consume before writing. A failed/ambiguous write must never auto-retry. + delete(s.requests, id) + connection := p.connection + p.mu.Unlock() + // Never hold the state lock during IO: rendering can check pending state + // without waiting for the network. The server arbitrates concurrent replies. + return p.write(connection, map[string]any{"id": json.RawMessage(id), "result": map[string]string{"decision": decision}}) +} diff --git a/internal/codex/session_approval_unix_test.go b/internal/codex/session_approval_unix_test.go new file mode 100644 index 0000000..c4d1f9f --- /dev/null +++ b/internal/codex/session_approval_unix_test.go @@ -0,0 +1,101 @@ +//go:build unix + +package codex + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/gorilla/websocket" +) + +func TestApprovalWireOneUseAndDisconnect(t *testing.T) { + received := make(chan map[string]json.RawMessage, 2) + upgrader := websocket.Upgrader{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + return + } + defer conn.Close() + for { + var msg map[string]json.RawMessage + if conn.ReadJSON(&msg) != nil { + return + } + received <- msg + } + })) + defer server.Close() + conn, _, err := websocket.DefaultDialer.Dial("ws"+strings.TrimPrefix(server.URL, "http"), nil) + if err != nil { + t.Fatal(err) + } + defer conn.Close() + states, c := approvalFixture(t, nil) + p := &daemonStatusProvider{connection: conn, contexts: states} + if !p.SessionApprovalPending(c.ApprovalToken) { + t.Fatal("missing pending") + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if err := p.RespondSessionApproval(ctx, c.ApprovalToken, "accept"); err == nil { + t.Fatal("cancelled response accepted") + } + if !p.SessionApprovalPending(c.ApprovalToken) { + t.Fatal("cancelled operation consumed request") + } + if err := p.RespondSessionApproval(context.Background(), c.ApprovalToken, "acceptForSession"); err == nil { + t.Fatal("persistent grant accepted") + } + if err := p.RespondSessionApproval(context.Background(), c.ApprovalToken, "accept"); err != nil { + t.Fatal(err) + } + select { + case msg := <-received: + if string(msg["id"]) != `"req"` || string(msg["result"]) != `{"decision":"accept"}` || len(msg) != 2 { + t.Fatalf("wrong response: %v", msg) + } + case <-time.After(time.Second): + t.Fatal("no decision sent") + } + if p.SessionApprovalPending(c.ApprovalToken) { + t.Fatal("consumed request still actionable") + } + if err := p.RespondSessionApproval(context.Background(), c.ApprovalToken, "decline"); err == nil { + t.Fatal("duplicate response allowed") + } + p.contexts, c = approvalFixture(t, nil) + if err := p.RespondSessionApproval(context.Background(), c.ApprovalToken, "decline"); err != nil { + t.Fatal(err) + } + select { + case msg := <-received: + if string(msg["result"]) != `{"decision":"decline"}` { + t.Fatal("wrong decline", msg) + } + case <-time.After(time.Second): + t.Fatal("missing decline") + } + p.contexts, c = approvalFixture(t, nil) + conn.Close() + if err := p.RespondSessionApproval(context.Background(), c.ApprovalToken, "accept"); err == nil { + t.Fatal("closed transport succeeded") + } + if p.SessionApprovalPending(c.ApprovalToken) { + t.Fatal("failed write may be retried") + } + p.contexts, c = approvalFixture(t, nil) + p.disconnect(conn) + if p.SessionApprovalPending(c.ApprovalToken) { + t.Fatal("disconnected request actionable") + } + if err := p.RespondSessionApproval(context.Background(), c.ApprovalToken, "accept"); err == nil { + t.Fatal("disconnected response allowed") + } +} diff --git a/internal/codex/session_context.go b/internal/codex/session_context.go new file mode 100644 index 0000000..4caffe9 --- /dev/null +++ b/internal/codex/session_context.go @@ -0,0 +1,232 @@ +package codex + +import ( + "bytes" + "encoding/json" + "io" + "os" + "strings" + "time" + "unicode" + + "github.com/charmbracelet/x/ansi" +) + +type SessionContextKind int + +const ( + SessionContextNone SessionContextKind = iota + SessionContextActivity + SessionContextReply + SessionContextQuestion + SessionContextApproval +) + +// SessionContext is a bounded, memory-only excerpt, never a generated summary. +// It is deliberately separate from token accounting and persisted preferences. +type SessionContext struct { + // ApprovalToken is an opaque, connection-local capability, never persisted. + ApprovalToken string + TurnID string + ItemID string + Kind SessionContextKind + Text string + At time.Time + ThreadID string + Source string +} + +const sessionContextLimit = 4096 + +// SanitizeSessionContext removes terminal escapes and control/bidi formatting. +// It does not promise to redact secrets embedded in ordinary message text. +func SanitizeSessionContext(text string) string { + text = ansi.Strip(strings.ToValidUTF8(text, "�")) + text = strings.Map(func(r rune) rune { + if r == '\n' || r == '\t' { + return r + } + if unicode.IsControl(r) || unicode.Is(unicode.Cf, r) { + return -1 + } + return r + }, text) + text = strings.ReplaceAll(text, "\t", " ") + runes := []rune(strings.TrimSpace(text)) + if len(runes) > sessionContextLimit { + return string(runes[:sessionContextLimit-1]) + "…" + } + return string(runes) +} + +func (c SessionContext) pending() bool { + return c.Kind == SessionContextQuestion || c.Kind == SessionContextApproval +} + +func preferSessionContext(a, b SessionContext) SessionContext { + if b.Text == "" { + return a + } + priority := func(c SessionContext) int { + if c.Kind == SessionContextApproval { + return 3 + } + if c.Kind == SessionContextQuestion { + return 2 + } + return 1 + } + pa, pb := priority(a), priority(b) + tie := b.At.Equal(a.At) && (b.ThreadID < a.ThreadID || b.ThreadID == a.ThreadID && b.Text < a.Text) + if a.Text == "" || pb > pa || pb == pa && (b.At.After(a.At) || tie) { + return b + } + return a +} + +type contextQuestion struct { + Question string `json:"question"` + Options []struct { + Label string `json:"label"` + Description string `json:"description"` + } `json:"options"` +} + +func questionContext(questions []contextQuestion) string { + var lines []string + for _, q := range questions { + lines = append(lines, q.Question) + for _, o := range q.Options { + line := "• " + o.Label + if o.Description != "" { + line += " — " + o.Description + } + lines = append(lines, line) + } + } + return strings.Join(lines, "\n") +} + +func contextCommand(raw json.RawMessage) string { + var value string + if json.Unmarshal(raw, &value) == nil { + return value + } + var parts []string + if json.Unmarshal(raw, &parts) == nil { + return strings.Join(parts, " ") + } + return "" +} + +func rolloutContextRecord(line []byte, cursor *rolloutCursor) (SessionContext, bool) { + var event struct { + Type string `json:"type"` + Timestamp time.Time `json:"timestamp"` + Ordinal *uint64 `json:"ordinal"` + Payload struct { + Type string `json:"type"` + Role string `json:"role"` + Phase string `json:"phase"` + Message string `json:"message"` + Last string `json:"last_agent_message"` + Reason string `json:"reason"` + Command json.RawMessage `json:"command"` + Questions []contextQuestion `json:"questions"` + IsBlocking *bool `json:"isBlocking"` + Content []struct { + Type string `json:"type"` + Text string `json:"text"` + } `json:"content"` + } `json:"payload"` + } + if json.Unmarshal(line, &event) != nil || !tokenRecordIsOwned(event.Ordinal, cursor.subagentHistoryStartOrdinal, event.Timestamp, cursor.nonRoot, cursor.startedAt) { + return SessionContext{}, false + } + c := SessionContext{At: event.Timestamp, ThreadID: cursor.threadID, Source: "LOCAL"} + p := event.Payload + if event.Type == "response_item" && p.Type == "message" && p.Role == "assistant" { + if p.Phase != "commentary" && p.Phase != "final_answer" { + return c, false + } + c.Kind = SessionContextActivity + if p.Phase == "final_answer" { + c.Kind = SessionContextReply + } + for _, part := range p.Content { + if part.Type == "output_text" { + c.Text += part.Text + "\n" + } + } + } else if event.Type == "event_msg" { + switch p.Type { + case "task_started", "turn_started", "user_message": + return SessionContext{}, true + case "task_complete", "turn_complete": + c.Kind, c.Text = SessionContextReply, p.Last + if p.Last == "" && cursor.preview.Kind == SessionContextReply { + return cursor.preview, true + } + case "agent_message": + c.Kind, c.Text = SessionContextActivity, p.Message + if p.Phase == "final_answer" { + c.Kind = SessionContextReply + } + case "request_user_input": + if p.IsBlocking != nil && !*p.IsBlocking { + return c, false + } + c.Kind, c.Text = SessionContextQuestion, questionContext(p.Questions) + case "exec_approval_request": + c.Kind, c.Text = SessionContextApproval, strings.TrimSpace(p.Reason+"\n"+contextCommand(p.Command)) + case "apply_patch_approval_request", "request_permissions": + c.Kind, c.Text = SessionContextApproval, p.Reason + case "exec_command_begin": + c.Kind, c.Text = SessionContextActivity, contextCommand(p.Command) + default: + return c, false + } + } else { + return c, false + } + if c.Kind == SessionContextActivity && cursor.preview.pending() && p.Type != "exec_command_begin" { + return cursor.preview, true + } + c.Text = SanitizeSessionContext(c.Text) + return c, true +} + +// Bootstrap only a bounded tail, not a complete conversation history. Files +// already being followed are handled by the existing incremental reader. +func latestSessionContext(path string, cursor *rolloutCursor) SessionContext { + f, err := os.Open(path) + if err != nil { + return SessionContext{} + } + defer f.Close() + info, err := f.Stat() + if err != nil { + return SessionContext{} + } + start := max(info.Size()-256*1024, 0) + if _, err := f.Seek(start, io.SeekStart); err != nil { + return SessionContext{} + } + data, err := io.ReadAll(io.LimitReader(f, 256*1024)) + if err != nil { + return SessionContext{} + } + lines := bytes.Split(data, []byte{'\n'}) + if start > 0 && len(lines) > 0 { + lines = lines[1:] + } + var latest SessionContext + copyCursor := *cursor + for _, line := range lines[:max(len(lines)-1, 0)] { + if c, ok := rolloutContextRecord(line, ©Cursor); ok { + latest = c + copyCursor.preview = c + } + } + return latest +} diff --git a/internal/codex/session_context_daemon.go b/internal/codex/session_context_daemon.go new file mode 100644 index 0000000..5a05b87 --- /dev/null +++ b/internal/codex/session_context_daemon.go @@ -0,0 +1,197 @@ +package codex + +import ( + "crypto/rand" + "encoding/json" + "strings" + "time" +) + +// Pending requests are keyed by JSON-RPC request id, so resolving one request +// cannot clear a different outstanding question/approval for the same thread. +type daemonContextState struct { + latest SessionContext + requests map[string]SessionContext + commands map[string]contextCommandItem +} + +type contextCommandItem struct{ Command, CWD string } + +func daemonContextEvent(states map[string]*daemonContextState, method string, id, raw json.RawMessage, now time.Time) { + var p struct { + TurnID string `json:"turnId"` + ItemID string `json:"itemId"` + CWD string `json:"cwd"` + Network json.RawMessage `json:"networkApprovalContext"` + Permissions json.RawMessage `json:"additionalPermissions"` + RequestedPermissions json.RawMessage `json:"permissions"` + Decisions []json.RawMessage `json:"availableDecisions"` + ThreadID string `json:"threadId"` + GrantRoot string `json:"grantRoot"` + RequestID json.RawMessage `json:"requestId"` + Reason string `json:"reason"` + Command json.RawMessage `json:"command"` + Questions []contextQuestion `json:"questions"` + IsBlocking *bool `json:"isBlocking"` + Item struct { + ID string `json:"id"` + CWD string `json:"cwd"` + Type string `json:"type"` + Text string `json:"text"` + Phase string `json:"phase"` + Command string `json:"command"` + } `json:"item"` + } + if json.Unmarshal(raw, &p) != nil || p.ThreadID == "" { + return + } + state := states[p.ThreadID] + if state == nil { + if len(states) >= 256 { + return + } + state = &daemonContextState{requests: map[string]SessionContext{}, commands: map[string]contextCommandItem{}} + states[p.ThreadID] = state + } + c := SessionContext{At: now, ThreadID: p.ThreadID, TurnID: p.TurnID, ItemID: p.ItemID, Source: "LIVE"} + switch method { + case "turn/started", "thread/closed": + delete(states, p.ThreadID) + return + case "turn/completed", "turn/interrupted": + clear(state.requests) + clear(state.commands) + return + case "serverRequest/resolved": + delete(state.requests, string(p.RequestID)) + return + case "item/commandExecution/requestApproval": + var command string + commandValid := len(p.Command) == 0 || string(p.Command) == "null" || json.Unmarshal(p.Command, &command) == nil + previous := state.commands[p.TurnID+"/"+p.ItemID] + if command == "" { + command = previous.Command + } + if p.CWD == "" { + p.CWD = previous.CWD + } + c.Kind = SessionContextApproval + c.Text = strings.TrimSpace(p.Reason + "\nCommand: " + command + "\nDirectory: " + p.CWD) + special := func(raw json.RawMessage) bool { return len(raw) > 0 && string(raw) != "null" } + if special(p.Network) { + c.Text += "\nNetwork request: " + string(p.Network) + } + if special(p.Permissions) { + c.Text += "\nPermissions: " + string(p.Permissions) + } + allowed := p.Decisions == nil + if !allowed { + var accept, decline bool + for _, raw := range p.Decisions { + accept = accept || string(raw) == `"accept"` + decline = decline || string(raw) == `"decline"` + } + allowed = accept && decline + } + // Fail closed if the display loses ANY command/request content, or if + // this is a broader grant rather than an ordinary command decision. + if commandValid && allowed && command != "" && p.CWD != "" && p.TurnID != "" && p.ItemID != "" && !special(p.Network) && !special(p.Permissions) && SanitizeSessionContext(c.Text) == c.Text && SanitizeSessionContext(command) == command && SanitizeSessionContext(p.CWD) == p.CWD { + c.ApprovalToken = rand.Text() + } + case "item/fileChange/requestApproval": + c.Kind, c.Text = SessionContextApproval, p.Reason + if c.Text == "" { + c.Text = "File changes requested" + } + if p.GrantRoot != "" { + c.Text += "\nRoot: " + p.GrantRoot + } + case "item/permissions/requestApproval": + c.Kind, c.Text = SessionContextApproval, p.Reason + if c.Text == "" { + c.Text = "Additional permissions requested" + } + if len(p.RequestedPermissions) > 0 { + c.Text += "\nPermissions: " + string(p.RequestedPermissions) + } + if p.CWD != "" { + c.Text += "\nDirectory: " + p.CWD + } + case "item/tool/requestUserInput", "tool/requestUserInput": + if p.IsBlocking != nil && !*p.IsBlocking { + return + } + c.Kind, c.Text = SessionContextQuestion, questionContext(p.Questions) + case "item/completed": + for key, request := range state.requests { + if p.Item.ID != "" && request.ItemID == p.Item.ID && request.TurnID == p.TurnID { + delete(state.requests, key) + } + } + delete(state.commands, p.TurnID+"/"+p.Item.ID) + if p.Item.Type != "agentMessage" { + return + } + c.Kind, c.Text = SessionContextActivity, p.Item.Text + if p.Item.Phase == "final_answer" { + c.Kind = SessionContextReply + } + case "item/started": + if p.Item.Type != "commandExecution" { + return + } + c.Kind, c.Text = SessionContextActivity, p.Item.Command + if state.commands == nil { + state.commands = map[string]contextCommandItem{} + } + if len(state.commands) < 64 { + // Keep one character beyond the display limit so oversized items + // remain ineligible for approval, without retaining unbounded text. + bounded := func(s string) string { + r := []rune(s) + if len(r) > sessionContextLimit+1 { + return string(r[:sessionContextLimit+1]) + } + return s + } + state.commands[p.TurnID+"/"+p.Item.ID] = contextCommandItem{bounded(p.Item.Command), bounded(p.Item.CWD)} + } + default: + return + } + c.Text = SanitizeSessionContext(c.Text) + if c.Text == "" { + return + } + if c.pending() { + if len(id) == 0 || string(id) == "null" { + return + } + if _, exists := state.requests[string(id)]; exists || len(state.requests) < 16 { + state.requests[string(id)] = c + } + } else { + state.latest = c + } +} + +func daemonContextSnapshot(states map[string]*daemonContextState, ids []string, statuses map[string]sessionRuntimeStatus) map[string]SessionContext { + out := map[string]SessionContext{} + for _, id := range ids { + state := states[id] + if state == nil { + continue + } + c := state.latest + for _, request := range state.requests { + if request.Kind == SessionContextApproval && statuses[id] != sessionRuntimeApproval || request.Kind == SessionContextQuestion && statuses[id] != sessionRuntimeInput { + continue + } + c = preferSessionContext(c, request) + } + if c.Text != "" { + out[id] = c + } + } + return out +} diff --git a/internal/codex/session_context_test.go b/internal/codex/session_context_test.go new file mode 100644 index 0000000..1ad4c1b --- /dev/null +++ b/internal/codex/session_context_test.go @@ -0,0 +1,147 @@ +package codex + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" + "unicode/utf8" +) + +func contextRecord(t *testing.T, kind string, payload any) []byte { + t.Helper() + b, err := json.Marshal(map[string]any{"type": kind, "timestamp": time.Now().UTC(), "payload": payload}) + if err != nil { + t.Fatal(err) + } + return b +} + +func TestSessionContextExtractsOnlyDisplayText(t *testing.T) { + cursor := &rolloutCursor{threadID: "root"} + for _, tc := range []struct { + payload any + kind SessionContextKind + contains string + }{ + {map[string]any{"type": "task_complete", "last_agent_message": "Pushed directly to main"}, SessionContextReply, "Pushed directly"}, + {map[string]any{"type": "request_user_input", "questions": []any{map[string]any{"question": "Which branch?", "options": []any{map[string]string{"label": "main", "description": "Default branch"}}}}}, SessionContextQuestion, "Default branch"}, + {map[string]any{"type": "exec_approval_request", "reason": "Allow push?", "command": []string{"git", "push"}}, SessionContextApproval, "git push"}, + {map[string]any{"type": "exec_command_begin", "command": "go test ./..."}, SessionContextActivity, "go test"}, + } { + c, ok := rolloutContextRecord(contextRecord(t, "event_msg", tc.payload), cursor) + if !ok || c.Kind != tc.kind || !strings.Contains(c.Text, tc.contains) || c.ThreadID != "root" || c.Source != "LOCAL" { + t.Fatalf("context=%+v", c) + } + } + for _, payload := range []map[string]any{ + {"type": "agent_reasoning", "text": "private reasoning"}, + {"type": "request_user_input", "isBlocking": false, "questions": []any{map[string]string{"question": "Not blocking"}}}, + } { + if c, ok := rolloutContextRecord(contextRecord(t, "event_msg", payload), cursor); ok { + t.Fatalf("retained excluded content: %+v", c) + } + } + if c, ok := rolloutContextRecord(contextRecord(t, "response_item", map[string]any{"type": "message", "role": "user", "content": []any{map[string]string{"type": "input_text", "text": "user secret"}}}), cursor); ok { + t.Fatalf("retained user prompt: %+v", c) + } +} + +func TestSessionContextSanitizationAndBound(t *testing.T) { + raw := "\x1b[31mhello\x1b[0m\x1b]8;;https://bad.example\aLINK\x1b]8;;\a\x00\r\u202E" + strings.Repeat("界", 5000) + got := SanitizeSessionContext(raw) + if !utf8.ValidString(got) || strings.ContainsAny(got, "\x1b\x00\r\u202E") || strings.Contains(got, "bad.example") || len([]rune(got)) != sessionContextLimit { + t.Fatalf("unsafe/unbounded context %q", got[:min(len(got), 100)]) + } +} + +func TestSessionContextRequestSurvivesCommentary(t *testing.T) { + cursor := &rolloutCursor{threadID: "root", preview: SessionContext{Kind: SessionContextQuestion, Text: "Which branch?", ThreadID: "root"}} + c, ok := rolloutContextRecord(contextRecord(t, "event_msg", map[string]string{"type": "agent_message", "message": "Waiting for your choice"}), cursor) + if !ok || c != cursor.preview { + t.Fatal("commentary displaced pending question", c) + } + c, ok = rolloutContextRecord(contextRecord(t, "event_msg", map[string]string{"type": "task_started"}), cursor) + if !ok || c.Text != "" { + t.Fatal("new turn kept question", c) + } + a := SessionContext{Kind: SessionContextReply, Text: "A", At: time.Now(), ThreadID: "a"} + b := a + b.ThreadID = "b" + if preferSessionContext(a, b) != preferSessionContext(b, a) { + t.Fatal("selection depends on map order") + } +} + +func TestSessionContextBootstrapAndOwnership(t *testing.T) { + cursor := &rolloutCursor{threadID: "root"} + path := filepath.Join(t.TempDir(), "rollout.jsonl") + complete := contextRecord(t, "event_msg", map[string]string{"type": "task_complete", "last_agent_message": "Finished"}) + if err := os.WriteFile(path, append(complete, '\n'), 0600); err != nil { + t.Fatal(err) + } + if c := latestSessionContext(path, cursor); c.Text != "Finished" { + t.Fatal(c) + } + start := contextRecord(t, "event_msg", map[string]string{"type": "task_started"}) + if err := os.WriteFile(path, append(append(append(complete, '\n'), start...), '\n'), 0600); err != nil { + t.Fatal(err) + } + if c := latestSessionContext(path, cursor); c.Text != "" { + t.Fatal("stale prior-turn reply", c) + } + // An inherited parent event must not be presented as the child's message. + boundary := uint64(10) + cursor.nonRoot = true + cursor.subagentHistoryStartOrdinal = &boundary + if c, ok := rolloutContextRecord([]byte(`{"type":"event_msg","ordinal":2,"payload":{"type":"task_complete","last_agent_message":"parent"}}`), cursor); ok { + t.Fatal("inherited context", c) + } +} + +func TestDaemonContextRequestLifecycle(t *testing.T) { + states := map[string]*daemonContextState{} + now := time.Now() + emit := func(method, id, params string) { + daemonContextEvent(states, method, json.RawMessage(id), json.RawMessage(params), now) + } + emit("item/completed", "", `{"threadId":"root","item":{"type":"agentMessage","text":"Last reply","phase":"final_answer"}}`) + emit("item/commandExecution/requestApproval", "1", `{"threadId":"root","reason":"Allow push?","command":"git push"}`) + emit("item/commandExecution/requestApproval", "2", `{"threadId":"root","reason":"Second approval","command":"git status"}`) + emit("serverRequest/resolved", "", `{"threadId":"root","requestId":1}`) + out := daemonContextSnapshot(states, []string{"root"}, map[string]sessionRuntimeStatus{"root": sessionRuntimeApproval}) + if c := out["root"]; c.Kind != SessionContextApproval || !strings.Contains(c.Text, "Second approval") { + t.Fatal(c) + } + emit("serverRequest/resolved", "", `{"threadId":"root","requestId":2}`) + out = daemonContextSnapshot(states, []string{"root"}, map[string]sessionRuntimeStatus{"root": sessionRuntimeIdle}) + if c := out["root"]; c.Text != "Last reply" || c.Kind != SessionContextReply { + t.Fatal(c) + } + emit("turn/started", "", `{"threadId":"root"}`) + if len(states) != 0 { + t.Fatal("stale context after next turn") + } + emit("item/tool/requestUserInput", "3", `{"threadId":"root","isBlocking":false,"questions":[{"question":"Optional feedback?"}]}`) + if len(states["root"].requests) != 0 { + t.Fatal("non-blocking question treated as pending input") + } +} + +func TestSessionContextGroupPrefersOutstandingChildRequest(t *testing.T) { + now := time.Now() + r := LiveUsageReader{files: map[string]*rolloutCursor{ + "root": {threadID: "root", lastModified: now, preview: SessionContext{Kind: SessionContextReply, Text: "Root done", At: now, ThreadID: "root"}}, + "child": {threadID: "child", parentThreadID: "root", nonRoot: true, lastModified: now, attention: sessionAttentionApproval, preview: SessionContext{Kind: SessionContextApproval, Text: "Child approval", At: now.Add(-time.Minute), ThreadID: "child"}}, + }} + sessions, _, _ := r.sessionSnapshots(now, map[string]struct{}{"root": {}, "child": {}}, true, nil) + if len(sessions) != 1 || sessions[0].Context.ThreadID != "child" { + t.Fatalf("sessions=%+v", sessions) + } + sessions, _, _ = r.sessionSnapshots(now, map[string]struct{}{"root": {}, "child": {}}, true, map[string]sessionRuntimeStatus{"child": sessionRuntimeWorking}) + if sessions[0].Context.Text != "Root done" { + t.Fatal("stale approval overrode exact working status", sessions) + } +} diff --git a/internal/i18n/locales/de.json b/internal/i18n/locales/de.json index bf4f565..5c49aeb 100644 --- a/internal/i18n/locales/de.json +++ b/internal/i18n/locales/de.json @@ -1,4 +1,20 @@ { + "[ APPROVE ONCE ]": "[ EINMAL ERLAUBEN ]", + "[ CONFIRM APPROVAL ]": "[ GENEHMIGUNG BESTÄTIGEN ]", + "[ DECLINE ]": "[ ABLEHNEN ]", + "Sending decision…": "Entscheidung wird gesendet…", + "Decision sent; check Codex for the outcome.": "Entscheidung gesendet; Ergebnis in Codex prüfen.", + "Decision unconfirmed; check Codex. Do not retry here.": "Entscheidung unbestätigt; Codex prüfen. Hier nicht erneut versuchen.", + "LAST REPLY": "LETZTE ANTWORT", + "QUESTION": "FRAGE", + "REQUEST": "ANFRAGE", + "LAST ACTIVITY": "LETZTE AKTIVITÄT", + "[H:SHOW]": "[H:ZEIGEN]", + "[H:HIDE]": "[H:VERBERGEN]", + "NO CONTEXT": "KEIN KONTEXT", + "REPLY IN CODEX": "IN CODEX ANTWORTEN", + "SESSION CONTEXT": "SITZUNGSKONTEXT", + "TURN COMPLETE": "TURN ABGESCHLOSSEN", "OBSERVED API-EQ // SPEND ~%s // 100%% ~%s // %s · N=%d": "BEOBACHTETES API-EQ // AUSGABEN ~%s // 100%% ~%s // %s · N=%d", "MED": "MITTEL", "RESULT MATRIX": "ERGEBNISMATRIX", diff --git a/internal/i18n/locales/en-GB.json b/internal/i18n/locales/en-GB.json index a364589..45d1b33 100644 --- a/internal/i18n/locales/en-GB.json +++ b/internal/i18n/locales/en-GB.json @@ -1,4 +1,20 @@ { + "[ APPROVE ONCE ]": "[ APPROVE ONCE ]", + "[ CONFIRM APPROVAL ]": "[ CONFIRM APPROVAL ]", + "[ DECLINE ]": "[ DECLINE ]", + "Sending decision…": "Sending decision…", + "Decision sent; check Codex for the outcome.": "Decision sent; check Codex for the outcome.", + "Decision unconfirmed; check Codex. Do not retry here.": "Decision unconfirmed; check Codex. Do not retry here.", + "LAST REPLY": "LAST REPLY", + "QUESTION": "QUESTION", + "REQUEST": "REQUEST", + "LAST ACTIVITY": "LAST ACTIVITY", + "[H:SHOW]": "[H:SHOW]", + "[H:HIDE]": "[H:HIDE]", + "NO CONTEXT": "NO CONTEXT", + "REPLY IN CODEX": "REPLY IN CODEX", + "SESSION CONTEXT": "SESSION CONTEXT", + "TURN COMPLETE": "TURN COMPLETE", "OBSERVED API-EQ // SPEND ~%s // 100%% ~%s // %s · N=%d": "OBSERVED API-EQ // SPEND ~%s // 100%% ~%s // %s · N=%d", "MED": "MED", "RESULT MATRIX": "RESULT MATRIX", diff --git a/internal/i18n/locales/es.json b/internal/i18n/locales/es.json index 4b7da00..36d7314 100644 --- a/internal/i18n/locales/es.json +++ b/internal/i18n/locales/es.json @@ -1,4 +1,20 @@ { + "[ APPROVE ONCE ]": "[ APROBAR UNA VEZ ]", + "[ CONFIRM APPROVAL ]": "[ CONFIRMAR APROBACIÓN ]", + "[ DECLINE ]": "[ RECHAZAR ]", + "Sending decision…": "Enviando decisión…", + "Decision sent; check Codex for the outcome.": "Decisión enviada; consulta el resultado en Codex.", + "Decision unconfirmed; check Codex. Do not retry here.": "Decisión sin confirmar; revisa Codex. No lo reintentes aquí.", + "LAST REPLY": "ÚLTIMA RESPUESTA", + "QUESTION": "PREGUNTA", + "REQUEST": "SOLICITUD", + "LAST ACTIVITY": "ÚLTIMA ACTIVIDAD", + "[H:SHOW]": "[H:VER]", + "[H:HIDE]": "[H:OCULTAR]", + "NO CONTEXT": "SIN CONTEXTO", + "REPLY IN CODEX": "RESPONDE EN CODEX", + "SESSION CONTEXT": "CONTEXTO DE SESIÓN", + "TURN COMPLETE": "TURNO COMPLETADO", "OBSERVED API-EQ // SPEND ~%s // 100%% ~%s // %s · N=%d": "API-EQ OBSERVADO // GASTO ~%s // 100%% ~%s // %s · N=%d", "MED": "MEDIO", "RESULT MATRIX": "MATRIZ DE RESULTADOS", diff --git a/internal/i18n/locales/fr.json b/internal/i18n/locales/fr.json index 916a16a..ec0a126 100644 --- a/internal/i18n/locales/fr.json +++ b/internal/i18n/locales/fr.json @@ -1,4 +1,20 @@ { + "[ APPROVE ONCE ]": "[ AUTORISER UNE FOIS ]", + "[ CONFIRM APPROVAL ]": "[ CONFIRMER L’AUTORISATION ]", + "[ DECLINE ]": "[ REFUSER ]", + "Sending decision…": "Envoi de la décision…", + "Decision sent; check Codex for the outcome.": "Décision envoyée ; consultez le résultat dans Codex.", + "Decision unconfirmed; check Codex. Do not retry here.": "Décision non confirmée ; vérifiez Codex. Ne réessayez pas ici.", + "LAST REPLY": "DERNIÈRE RÉPONSE", + "QUESTION": "QUESTION", + "REQUEST": "DEMANDE", + "LAST ACTIVITY": "DERNIÈRE ACTIVITÉ", + "[H:SHOW]": "[H:VOIR]", + "[H:HIDE]": "[H:MASQUER]", + "NO CONTEXT": "AUCUN CONTEXTE", + "REPLY IN CODEX": "RÉPONDRE DANS CODEX", + "SESSION CONTEXT": "CONTEXTE DE SESSION", + "TURN COMPLETE": "TOUR TERMINÉ", "OBSERVED API-EQ // SPEND ~%s // 100%% ~%s // %s · N=%d": "API-EQ OBSERVÉ // DÉPENSE ~%s // 100%% ~%s // %s · N=%d", "MED": "MOYEN", "RESULT MATRIX": "TABLEAU DES RÉSULTATS", diff --git a/internal/i18n/locales/it.json b/internal/i18n/locales/it.json index 1d54fde..8f2f991 100644 --- a/internal/i18n/locales/it.json +++ b/internal/i18n/locales/it.json @@ -1,4 +1,20 @@ { + "[ APPROVE ONCE ]": "[ APPROVA UNA VOLTA ]", + "[ CONFIRM APPROVAL ]": "[ CONFERMA APPROVAZIONE ]", + "[ DECLINE ]": "[ RIFIUTA ]", + "Sending decision…": "Invio della decisione…", + "Decision sent; check Codex for the outcome.": "Decisione inviata; verifica l’esito in Codex.", + "Decision unconfirmed; check Codex. Do not retry here.": "Decisione non confermata; controlla Codex. Non riprovare qui.", + "LAST REPLY": "ULTIMA RISPOSTA", + "QUESTION": "DOMANDA", + "REQUEST": "RICHIESTA", + "LAST ACTIVITY": "ULTIMA ATTIVITÀ", + "[H:SHOW]": "[H:MOSTRA]", + "[H:HIDE]": "[H:NASCONDI]", + "NO CONTEXT": "NESSUN CONTESTO", + "REPLY IN CODEX": "RISPONDI IN CODEX", + "SESSION CONTEXT": "CONTESTO SESSIONE", + "TURN COMPLETE": "TURNO COMPLETATO", "OBSERVED API-EQ // SPEND ~%s // 100%% ~%s // %s · N=%d": "API-EQ OSSERVATO // SPESA ~%s // 100%% ~%s // %s · N=%d", "MED": "MEDIO", "RESULT MATRIX": "MATRICE RISULTATI", diff --git a/internal/i18n/locales/ja.json b/internal/i18n/locales/ja.json index 8e104ad..dac85b1 100644 --- a/internal/i18n/locales/ja.json +++ b/internal/i18n/locales/ja.json @@ -1,4 +1,20 @@ { + "[ APPROVE ONCE ]": "[ 今回のみ許可 ]", + "[ CONFIRM APPROVAL ]": "[ 許可を確定 ]", + "[ DECLINE ]": "[ 拒否 ]", + "Sending decision…": "決定を送信中…", + "Decision sent; check Codex for the outcome.": "決定を送信しました。結果は Codex で確認してください。", + "Decision unconfirmed; check Codex. Do not retry here.": "決定を確認できません。Codex を確認し、ここでは再試行しないでください。", + "LAST REPLY": "直近の返信", + "QUESTION": "質問", + "REQUEST": "リクエスト", + "LAST ACTIVITY": "直近の動作", + "[H:SHOW]": "[H:表示]", + "[H:HIDE]": "[H:非表示]", + "NO CONTEXT": "情報なし", + "REPLY IN CODEX": "CODEX で回答してください", + "SESSION CONTEXT": "セッション情報", + "TURN COMPLETE": "ターン完了", "OBSERVED API-EQ // SPEND ~%s // 100%% ~%s // %s · N=%d": "観測 API-EQ // 支出 ~%s // 100%% ~%s // %s · N=%d", "MED": "中", "RESULT MATRIX": "結果一覧", diff --git a/internal/i18n/locales/nl.json b/internal/i18n/locales/nl.json index 8be0b08..b00ff98 100644 --- a/internal/i18n/locales/nl.json +++ b/internal/i18n/locales/nl.json @@ -1,4 +1,20 @@ { + "[ APPROVE ONCE ]": "[ EENMALIG GOEDKEUREN ]", + "[ CONFIRM APPROVAL ]": "[ GOEDKEURING BEVESTIGEN ]", + "[ DECLINE ]": "[ WEIGEREN ]", + "Sending decision…": "Beslissing verzenden…", + "Decision sent; check Codex for the outcome.": "Beslissing verzonden; bekijk het resultaat in Codex.", + "Decision unconfirmed; check Codex. Do not retry here.": "Beslissing niet bevestigd; controleer Codex. Probeer hier niet opnieuw.", + "LAST REPLY": "LAATSTE ANTWOORD", + "QUESTION": "VRAAG", + "REQUEST": "VERZOEK", + "LAST ACTIVITY": "LAATSTE ACTIVITEIT", + "[H:SHOW]": "[H:TOON]", + "[H:HIDE]": "[H:VERBERG]", + "NO CONTEXT": "GEEN CONTEXT", + "REPLY IN CODEX": "ANTWOORD IN CODEX", + "SESSION CONTEXT": "SESSIECONTEXT", + "TURN COMPLETE": "BEURT VOLTOOID", "OBSERVED API-EQ // SPEND ~%s // 100%% ~%s // %s · N=%d": "WAARGENOMEN API-EQ // VERBRUIK ~%s // 100%% ~%s // %s · N=%d", "MED": "GEMIDDELD", "RESULT MATRIX": "RESULTATENMATRIX", diff --git a/internal/i18n/locales/ru.json b/internal/i18n/locales/ru.json index c9a27c4..086d553 100644 --- a/internal/i18n/locales/ru.json +++ b/internal/i18n/locales/ru.json @@ -1,4 +1,20 @@ { + "[ APPROVE ONCE ]": "[ РАЗРЕШИТЬ ОДИН РАЗ ]", + "[ CONFIRM APPROVAL ]": "[ ПОДТВЕРДИТЬ РАЗРЕШЕНИЕ ]", + "[ DECLINE ]": "[ ОТКЛОНИТЬ ]", + "Sending decision…": "Отправка решения…", + "Decision sent; check Codex for the outcome.": "Решение отправлено; проверьте результат в Codex.", + "Decision unconfirmed; check Codex. Do not retry here.": "Решение не подтверждено; проверьте Codex. Не повторяйте здесь.", + "LAST REPLY": "ПОСЛЕДНИЙ ОТВЕТ", + "QUESTION": "ВОПРОС", + "REQUEST": "ЗАПРОС", + "LAST ACTIVITY": "ПОСЛЕДНЯЯ АКТИВНОСТЬ", + "[H:SHOW]": "[H:ПОКАЗАТЬ]", + "[H:HIDE]": "[H:СКРЫТЬ]", + "NO CONTEXT": "НЕТ КОНТЕКСТА", + "REPLY IN CODEX": "ОТВЕТЬТЕ В CODEX", + "SESSION CONTEXT": "КОНТЕКСТ СЕССИИ", + "TURN COMPLETE": "ХОД ЗАВЕРШЁН", "OBSERVED API-EQ // SPEND ~%s // 100%% ~%s // %s · N=%d": "НАБЛЮДАЕМЫЙ API-EQ // РАСХОД ~%s // 100%% ~%s // %s · N=%d", "MED": "СРЕДНЯЯ", "RESULT MATRIX": "ТАБЛИЦА РЕЗУЛЬТАТОВ", diff --git a/internal/i18n/locales/zh-Hans.json b/internal/i18n/locales/zh-Hans.json index 39ff810..13a46c2 100644 --- a/internal/i18n/locales/zh-Hans.json +++ b/internal/i18n/locales/zh-Hans.json @@ -1,4 +1,20 @@ { + "[ APPROVE ONCE ]": "[ 仅允许一次 ]", + "[ CONFIRM APPROVAL ]": "[ 确认批准 ]", + "[ DECLINE ]": "[ 拒绝 ]", + "Sending decision…": "正在发送决定…", + "Decision sent; check Codex for the outcome.": "决定已发送;请在 Codex 中查看结果。", + "Decision unconfirmed; check Codex. Do not retry here.": "决定未确认;请检查 Codex。不要在此重试。", + "LAST REPLY": "最近回复", + "QUESTION": "问题", + "REQUEST": "请求", + "LAST ACTIVITY": "最近活动", + "[H:SHOW]": "[H:显示]", + "[H:HIDE]": "[H:隐藏]", + "NO CONTEXT": "无上下文", + "REPLY IN CODEX": "请在 CODEX 中回复", + "SESSION CONTEXT": "会话上下文", + "TURN COMPLETE": "轮次完成", "OBSERVED API-EQ // SPEND ~%s // 100%% ~%s // %s · N=%d": "观测 API-EQ // 支出 ~%s // 100%% ~%s // %s · N=%d", "MED": "中", "RESULT MATRIX": "结果表", diff --git a/internal/ui/localisation_test.go b/internal/ui/localisation_test.go index 08cdb0f..608176b 100644 --- a/internal/ui/localisation_test.go +++ b/internal/ui/localisation_test.go @@ -46,6 +46,9 @@ func TestLocalisedScreensHelper(t *testing.T) { } t.Run("benchmark_click_surfaces", TestBenchmarkRenderedClickSurfacesMatchHitTestingAcrossSizes) t.Run("monitor_click_surfaces", TestMonitorButtonBoxesMatchEnabledHitSurfacesAcrossSizes) + t.Run("monitor_context_surfaces", TestMonitorContextResponsiveHitTargets) + t.Run("monitor_privacy_surfaces", TestMonitorContextPrivacyAndDismissRenderedTargets) + t.Run("monitor_approval_surfaces", TestMonitorApprovalRenderedTargets) t.Run("tab_click_surfaces", TestEveryRenderedTabCellIsClickableAcrossWidths) t.Run("reset_click_surfaces", TestQuotaResetRenderedHitSurfaces) t.Run("quota_estimator", TestQuotaAPIEstimatorLearnsRangeAndCurrentSpend) diff --git a/internal/ui/model.go b/internal/ui/model.go index 6c596ca..eab6c8e 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -43,6 +43,13 @@ type BenchmarkTaskProvider interface { } type Model struct { + monitorContextHidden bool + monitorContextDetail string + monitorContextScroll int + monitorContextHover string + monitorApprovalConfirm string + monitorApprovalBusy bool + monitorApprovalNotice string history accountHistoryState resetThreshold int resetHovered bool @@ -207,6 +214,7 @@ type monitorSample struct { } type monitorSessionDismissal struct { + preview codex.SessionContext latest int64 lastActivity time.Time callSequence uint64 @@ -217,6 +225,7 @@ type monitorSessionDismissal struct { } type monitorSession struct { + preview codex.SessionContext id string workingDirectory string baseline int64 @@ -412,6 +421,18 @@ func (m Model) Init() tea.Cmd { func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) { switch message := message.(type) { + case monitorApprovalResult: + m.monitorApprovalBusy = false + m.monitorApprovalConfirm = "" + if m.monitorContextDetail != message.sessionID { + m.monitorApprovalNotice = "" + return m, nil + } + m.monitorApprovalNotice = i18n.Text("Decision sent; check Codex for the outcome.") + if message.err != nil { + m.monitorApprovalNotice = i18n.Text("Decision unconfirmed; check Codex. Do not retry here.") + } + return m, nil case quotaResetResult: m.resetBusy = false if message.err != nil { @@ -430,6 +451,13 @@ func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) { m.loading = true return m, m.fetch() case tea.KeyPressMsg: + if m.meterView == viewMonitor { + next, cmd, handled := m.updateMonitorContextKey(strings.ToLower(message.String())) + m = next + if handled { + return next, cmd + } + } if m.meterView == viewUsage { if action, ok := historyKey(strings.ToLower(message.String())); ok { m.activateHistory(action) @@ -625,6 +653,13 @@ func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) { } } case tea.MouseMsg: + if m.meterView == viewMonitor { + next, cmd, handled := m.updateMonitorContextMouse(message) + m = next + if handled { + return next, cmd + } + } mouse := message.Mouse() _, clicked := message.(tea.MouseClickMsg) m.history.hovered = 0 @@ -1000,6 +1035,10 @@ func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) { } func (m Model) pressViewTab(view meterViewID) (tea.Model, tea.Cmd) { + if view != viewMonitor { + m.monitorContextDetail = "" + m.monitorContextHover = "" + } m.resetConfirmUntil = time.Time{} if !m.resetBusy { m.resetNotice = "" @@ -2088,6 +2127,8 @@ func (m Model) monitorHasVisibleWaitingSession() bool { } func (m *Model) resetMonitorFromSnapshot(message monitorFetchedMsg, paused bool) { + m.monitorContextDetail = "" + m.monitorContextScroll = 0 m.monitorStartedAt = message.at m.monitorStoppedAt = time.Time{} m.monitorBaseline = message.usage.TotalTokens @@ -2179,6 +2220,7 @@ func (m *Model) resumeMonitorSessions(usage codex.LiveUsageSnapshot, observedAt session.agentCount = max(session.agentCount, update.AgentCount) session.active = update.Active session.attention = update.Attention + session.preview = update.Context session.callSequence = latestModelCallSequence(update.ModelCalls) session.turnSequence = latestTurnTimingSequence(update.TurnTimings) if update.WorkingDirectory != "" { @@ -2361,6 +2403,7 @@ func (m *Model) startMonitorSessions(usage codex.LiveUsageSnapshot, observedAt t startedAt: observedAt, lastActivity: session.LastActivity, agentCount: session.AgentCount, active: session.Active, attention: session.Attention, + preview: session.Context, displayed: session.Active, unattributed: session.Unattributed, callSequence: latestModelCallSequence(session.ModelCalls), turnSequence: latestTurnTimingSequence(session.TurnTimings), @@ -2392,6 +2435,7 @@ func (m *Model) syncMonitorSessions(usage codex.LiveUsageSnapshot, observedAt ti latest: update.TotalTokens, graphStart: 0, startedAt: startedAt, lastActivity: update.LastActivity, agentCount: update.AgentCount, active: update.Active, attention: update.Attention, + preview: update.Context, displayed: update.Active || update.TotalTokens > 0 || len(update.ModelCalls) > 0 || len(update.TurnTimings) > 0, unattributed: update.Unattributed, } @@ -2409,6 +2453,7 @@ func (m *Model) syncMonitorSessions(usage codex.LiveUsageSnapshot, observedAt ti session.agentCount = max(session.agentCount, update.AgentCount) session.active = update.Active session.attention = update.Attention + session.preview = update.Context session.displayed = session.displayed || update.Active || update.TotalTokens > session.baseline || len(update.ModelCalls) > 0 || len(update.TurnTimings) > 0 if update.WorkingDirectory != "" { @@ -2456,6 +2501,7 @@ func (m *Model) dismissMonitorSession(id string) { callSequence: session.callSequence, turnSequence: session.turnSequence, attention: session.attention, + preview: session.preview, } m.monitorSessions = m.visibleMonitorSessionCount() maximumScroll := max(m.monitorSessions-m.monitorPageSize(), 0) @@ -2474,6 +2520,7 @@ func (m *Model) restoreMonitorSessionOnActivity(session *monitorSession) { session.callSequence > dismissal.callSequence || session.turnSequence > dismissal.turnSequence || newAttention || + (session.preview.Text != "" && session.preview != dismissal.preview) || (dismissal.inactiveObserved && session.active) { delete(m.monitorDismissed, session.id) } diff --git a/internal/ui/monitor.go b/internal/ui/monitor.go index 9ce6088..cc8bc0c 100644 --- a/internal/ui/monitor.go +++ b/internal/ui/monitor.go @@ -80,6 +80,9 @@ func layoutMonitorArea(width, height int) monitorGeometry { } func (m Model) renderMonitorArea(width, height int, colors palette) monitorView { + if m.monitorContextDetail != "" && !m.monitorContextHidden { + return monitorView{view: m.renderMonitorContextDetail(width, height, colors)} + } layout := layoutMonitorArea(width, height) readout := m.renderMonitorReadout(layout.readoutWidth, layout.topHeight, colors) @@ -169,7 +172,11 @@ func (m Model) renderMonitorReadout(width, height int, colors palette) string { telemetry := i18n.Format("LOCAL SESSIONS %d // LAST %s AGO", m.monitorSessions, last) lines = append(lines, colors.dimmed().Render(ansi.Truncate(telemetry, innerWidth, ""))) } - return frameSized(width, max(height-2, 1), i18n.Text("MONITOR READOUT"), strings.Join(lines, "\n"), colors.primary, colors) + action := "" + if len(m.monitorSessionData) > 0 && width >= 16 { + action = m.renderContextAction("privacy", m.monitorPrivacyLabel(), colors) + } + return frameSizedWithTitleAction(width, max(height-2, 1), i18n.Text("MONITOR READOUT"), action, strings.Join(lines, "\n"), colors.primary, colors) } func (m Model) renderMonitorButton(width, height int, label string, id footerButtonID, enabled bool, colors palette) string { @@ -242,7 +249,9 @@ func (m Model) monitorSessionPage(height int) ([]monitorSession, []int, string) if rowCount < visibleCount { pageLabel = i18n.Format("ROWS %d-%d/%d", start+1, start+rowCount, visibleCount) } - rowHeights := distributeSpace(max(height-(rowCount-1), rowCount), rowCount) + // Joining framed rows with a newline adds no blank row. Allocate the full + // height so no row falls below the three lines its frame actually renders. + rowHeights := distributeSpace(max(height, rowCount), rowCount) return visible, rowHeights, pageLabel } @@ -256,6 +265,9 @@ func (m Model) renderMonitorSessionRow(width, height int, session monitorSession return m.renderMonitorGraphSamples(width, height, session.samples, i18n.Text("TOKENS"), rowColors) } metrics := m.renderMonitorSessionMetrics(metricsWidth, height, session, pageLabel, rowColors) + if !m.monitorContextHidden && session.preview.Text != "" { + return m.renderMonitorContextRow(width, height, metrics, session, rowColors) + } title := i18n.Text("TOKEN BARS") graph := m.renderMonitorGraphSamples(graphWidth, height, session.samples, title, rowColors) return lipgloss.JoinHorizontal(lipgloss.Top, metrics, " ", graph) @@ -291,6 +303,9 @@ func (m Model) renderMonitorSessionMetrics(width, height int, session monitorSes } if session.attention != codex.SessionAttentionNone { status = monitorAttentionStatus(session.attention) + if session.preview.Kind == codex.SessionContextReply && session.attention == codex.SessionAttentionInput { + status = i18n.Text("TURN COMPLETE") + } } innerWidth := max(width-4, 1) memberLabel := "ROOT" @@ -309,7 +324,7 @@ func (m Model) renderMonitorSessionMetrics(width, height int, session monitorSes lines := make([]string, 0, bodyRows) if session.attention != codex.SessionAttentionNone { badge := lipgloss.NewStyle().Bold(true).Foreground(colors.background).Background(colors.warning) - lines = append(lines, badge.Render(ansi.Truncate(" ● "+monitorAttentionLabel(session.attention)+" ", innerWidth, ""))) + lines = append(lines, badge.Render(ansi.Truncate(" ● "+monitorSessionAttentionLabel(session)+" ", innerWidth, ""))) } if len(lines) < bodyRows { lines = append(lines, colors.label().Render(ansi.Truncate(usageLine, innerWidth, ""))) @@ -560,6 +575,10 @@ func plural(count int, singular, plural string) string { } func (m Model) renderMonitorGraphSamples(width, height int, samples []monitorSample, heading string, colors palette) string { + return m.renderMonitorGraphWithAction(width, height, samples, heading, "", colors) +} + +func (m Model) renderMonitorGraphWithAction(width, height int, samples []monitorSample, heading, action string, colors palette) string { innerWidth := max(width-4, 1) plotHeight := max(height-2, 1) const sampleWidth = 2 // one block-wide bar plus one cell of breathing room @@ -626,7 +645,7 @@ func (m Model) renderMonitorGraphSamples(width, height int, samples []monitorSam } lines[row] = renderMonitorBarRow(runes, colors) } - return frameSized(width, max(height-2, 1), ansi.Truncate(title, max(innerWidth-4, 1), ""), strings.Join(lines, "\n"), colors.primary, colors) + return frameSizedWithTitleAction(width, max(height-2, 1), ansi.Truncate(title, max(innerWidth-4, 1), ""), action, strings.Join(lines, "\n"), colors.primary, colors) } func renderMonitorBarRow(cells []rune, colors palette) string { @@ -647,6 +666,9 @@ func renderMonitorBarRow(cells []rune, colors palette) string { } func (m Model) monitorButtonAt(x, y int) footerButtonID { + if m.monitorContextDetail != "" && !m.monitorContextHidden { + return footerButtonNone + } if m.loading && len(m.snapshot.Meters()) == 0 { return footerButtonNone } @@ -663,6 +685,9 @@ func (m Model) monitorButtonAt(x, y int) footerButtonID { } func (m Model) monitorSessionDismissAt(x, y int) (string, bool) { + if m.monitorContextDetail != "" && !m.monitorContextHidden { + return "", false + } if m.meterView != viewMonitor || (m.loading && len(m.snapshot.Meters()) == 0) { return "", false } diff --git a/internal/ui/monitor_approval.go b/internal/ui/monitor_approval.go new file mode 100644 index 0000000..1890640 --- /dev/null +++ b/internal/ui/monitor_approval.go @@ -0,0 +1,71 @@ +package ui + +import ( + "context" + "time" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/merefield/codexometer/internal/codex" + "github.com/merefield/codexometer/internal/i18n" +) + +type monitorApprovalResult struct { + sessionID string + err error +} + +func (m Model) monitorApprovalToken() string { + if m.monitorContextHidden || m.monitorApprovalBusy { + return "" + } + s, ok := m.contextDetailSession() + p, supported := m.fetcher.(codex.SessionApprovalClient) + if !ok || !supported || s.preview.Kind != codex.SessionContextApproval || !p.SessionApprovalPending(s.preview.ApprovalToken) { + return "" + } + return s.preview.ApprovalToken +} + +func (m Model) monitorApprovalLabels() (string, string) { + a := i18n.Text("[ APPROVE ONCE ]") + if token := m.monitorApprovalToken(); token != "" && m.monitorApprovalConfirm == token { + a = i18n.Text("[ CONFIRM APPROVAL ]") + } + return a, i18n.Text("[ DECLINE ]") +} + +func (m Model) monitorApprovalControls(width, height int) bool { + _, b := m.monitorApprovalLabels() + return height >= 6 && width-4 >= monitorApprovalSlotWidth()+lipgloss.Width(b)+2 && m.monitorApprovalToken() != "" +} + +func monitorApprovalSlotWidth() int { + return max(lipgloss.Width(i18n.Text("[ APPROVE ONCE ]")), lipgloss.Width(i18n.Text("[ CONFIRM APPROVAL ]"))) +} + +func (m Model) monitorApprovalAction(action string) (Model, tea.Cmd, bool) { + token := m.monitorApprovalToken() + if token == "" { + m.monitorApprovalConfirm = "" + return m, nil, true + } + if action == "approve" && m.monitorApprovalConfirm != token { + m.monitorApprovalConfirm = token + return m, nil, true + } + decision := "decline" + if action == "approve" { + decision = "accept" + } + m.monitorApprovalConfirm = "" + m.monitorApprovalBusy = true + m.monitorApprovalNotice = i18n.Text("Sending decision…") + p := m.fetcher.(codex.SessionApprovalClient) + id := m.monitorContextDetail + return m, func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + return monitorApprovalResult{id, p.RespondSessionApproval(ctx, token, decision)} + }, true +} diff --git a/internal/ui/monitor_approval_test.go b/internal/ui/monitor_approval_test.go new file mode 100644 index 0000000..dc12124 --- /dev/null +++ b/internal/ui/monitor_approval_test.go @@ -0,0 +1,115 @@ +package ui + +import ( + "context" + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/charmbracelet/x/ansi" + "github.com/merefield/codexometer/internal/codex" +) + +type approvalTestClient struct{ token, decision string } + +func (c *approvalTestClient) Fetch(context.Context) (codex.Snapshot, error) { + return codex.DemoSnapshot(), nil +} +func (c *approvalTestClient) SessionApprovalPending(token string) bool { + return token != "" && token == c.token +} +func (c *approvalTestClient) RespondSessionApproval(_ context.Context, token, decision string) error { + c.decision = decision + c.token = "" + return nil +} + +func approvalTestModel() Model { + m := contextTestModel() + m.fetcher = &approvalTestClient{token: "live"} + m.monitorSessionData[0].preview = codex.SessionContext{Kind: codex.SessionContextApproval, Text: "Command: git push\nDirectory: /work", Source: "LIVE", ApprovalToken: "live"} + m.openMonitorContext(m.monitorSessionData[0].id) + return m +} + +func TestMonitorApprovalRenderedTargets(t *testing.T) { + for _, width := range []int{40, 60, 80, 120, 180} { + for _, height := range []int{12, 24, 40} { + for _, confirmed := range []bool{false, true} { + m := approvalTestModel() + m.width = width + m.height = height + if confirmed { + m.monitorApprovalConfirm = "live" + } + out := m.render() + if lipgloss.Width(out) > width || lipgloss.Height(out) > height { + t.Fatalf("overflow %dx%d", width, height) + } + a, b := m.monitorApprovalLabels() + g := m.dashboardLayout() + shown := m.monitorApprovalControls(g.contentWidth, g.meterHeight) + for label, action := range map[string]string{a: "approve", b: "decline"} { + found := false + for y, line := range strings.Split(ansi.Strip(out), "\n") { + i := strings.Index(line, label) + if i < 0 { + continue + } + found = true + x := lipgloss.Width(line[:i]) + for dx := 0; dx < lipgloss.Width(label); dx++ { + if got := m.monitorContextAt(x+dx, y); got != action { + t.Fatalf("%s miss at %d,%d got %q\n%s", action, x+dx, y, got, ansi.Strip(out)) + } + } + updated, cmd := m.Update(tea.MouseClickMsg(tea.Mouse{X: x, Y: y, Button: tea.MouseLeft})) + u := updated.(Model) + if action == "approve" && !confirmed { + if cmd != nil || u.monitorApprovalConfirm != "live" { + t.Fatal("first click must only confirm") + } + } else if cmd == nil || !u.monitorApprovalBusy { + t.Fatal("decision was not queued") + } + } + if found != shown { + t.Fatalf("render/hit visibility mismatch %dx%d", width, height) + } + } + } + } + } +} + +func TestMonitorApprovalStaleAndKeyboardSafety(t *testing.T) { + m := approvalTestModel() + m, cmd, _ := m.monitorApprovalAction("approve") + if cmd != nil { + t.Fatal("first click sent decision") + } + client := m.fetcher.(*approvalTestClient) + client.token = "replacement" + m, cmd, _ = m.monitorApprovalAction("approve") + if cmd != nil || m.monitorApprovalConfirm != "" { + t.Fatal("stale confirmation submitted") + } + client.token = "live" + for _, key := range []string{"a", "y", "enter"} { + copy := m + _, cmd, _ := copy.updateMonitorContextKey(key) + if cmd != nil || client.decision != "" { + t.Fatal("keyboard approved") + } + } + m.monitorContextHidden = true + if m.monitorApprovalToken() != "" { + t.Fatal("hidden preview actionable") + } + m.monitorContextHidden = false + m.monitorSessionData[0].preview.ApprovalToken = "" + if m.monitorApprovalToken() != "" { + t.Fatal("local request actionable") + } +} diff --git a/internal/ui/monitor_context.go b/internal/ui/monitor_context.go new file mode 100644 index 0000000..5ac88e0 --- /dev/null +++ b/internal/ui/monitor_context.go @@ -0,0 +1,316 @@ +package ui + +import ( + "strings" + "time" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/charmbracelet/x/ansi" + "github.com/merefield/codexometer/internal/codex" + "github.com/merefield/codexometer/internal/i18n" +) + +const monitorContextInfo = "[i]" + +func monitorSessionAttentionLabel(s monitorSession) string { + if s.attention == codex.SessionAttentionInput && s.preview.Kind == codex.SessionContextReply { + return i18n.Text("TURN COMPLETE") + } + return monitorAttentionLabel(s.attention) +} + +func contextTitle(c codex.SessionContext) string { + switch c.Kind { + case codex.SessionContextReply: + return i18n.Text("LAST REPLY") + case codex.SessionContextQuestion: + return i18n.Text("QUESTION") + case codex.SessionContextApproval: + return i18n.Text("REQUEST") + default: + return i18n.Text("LAST ACTIVITY") + } +} + +func (m Model) monitorPrivacyLabel() string { + if m.monitorContextHidden { + return i18n.Text("[H:SHOW]") + } + return i18n.Text("[H:HIDE]") +} + +func (m Model) renderContextAction(id, label string, colors palette) string { + s := colors.label() + if m.monitorContextHover == id { + s = s.Foreground(colors.background).Background(colors.primary) + } + return s.Render(label) +} + +func contextActionRect(width, y int, label string) (monitorRect, bool) { + w := lipgloss.Width(label) + return monitorRect{x: width - w - 2, y: y, width: w, height: 1}, width >= w+8 +} + +// Retain the metrics width so its dismiss target never shifts. Split only the +// space previously owned by the graph; narrow terminals prioritise the text. +func contextColumns(width int, s monitorSession) (metrics, context, graph int) { + metrics, graph, _ = monitorSessionColumnWidths(width) + if s.preview.Kind == codex.SessionContextActivity && s.attention == codex.SessionAttentionNone { + return + } + if width >= 100 { + context = graph / 2 + graph -= context + 1 + } else { + context, graph = graph, 0 + } + return +} + +func contextAge(c codex.SessionContext) string { + if c.At.IsZero() { + return "--" + } + return compactDuration(max(time.Since(c.At), 0)) +} + +func (m Model) renderMonitorContextRow(width, height int, metrics string, s monitorSession, colors palette) string { + _, cw, gw := contextColumns(width, s) + info := m.renderContextAction(s.id, monitorContextInfo, colors) + if cw == 0 { + graph := m.renderMonitorGraphWithAction(gw, height, s.samples, i18n.Text("TOKEN BARS"), info, colors) + return lipgloss.JoinHorizontal(lipgloss.Top, metrics, " ", graph) + } + inner := max(cw-4, 1) + text := codex.SanitizeSessionContext(s.preview.Text) + lines := strings.Split(ansi.Hardwrap(text, inner, true), "\n") + bodyRows := max(height-2, 1) + // At most two text rows keep the preview compact; the detail has the rest. + textRows := min(bodyRows, 2) + if len(lines) > textRows { + lines = lines[:textRows] + lines[textRows-1] = ansi.Truncate(lines[textRows-1], max(inner-1, 0), "") + "…" + } + if bodyRows > textRows { + lines = append(lines, s.preview.Source+" // "+shortSessionID(s.preview.ThreadID)+" // "+contextAge(s.preview)) + } + for i := range lines { + lines[i] = colors.label().Render(ansi.Truncate(lines[i], inner, "")) + } + panel := frameSizedWithTitleAction(cw, max(height-2, 1), contextTitle(s.preview), info, strings.Join(lines, "\n"), colors.primary, colors) + row := lipgloss.JoinHorizontal(lipgloss.Top, metrics, " ", panel) + if gw > 0 { + row = lipgloss.JoinHorizontal(lipgloss.Top, row, " ", m.renderMonitorGraphSamples(gw, height, s.samples, i18n.Text("TOKEN BARS"), colors)) + } + return row +} + +func (m Model) contextDetailSession() (monitorSession, bool) { + for _, s := range m.monitorSessionData { + if s.id == m.monitorContextDetail && m.monitorSessionVisible(s) { + return s, true + } + } + return monitorSession{}, false +} + +func (m Model) contextDetailLines(width int) []string { + s, ok := m.contextDetailSession() + if !ok || s.preview.Text == "" { + return []string{i18n.Text("NO CONTEXT")} + } + header := contextTitle(s.preview) + " // " + shortSessionID(s.preview.ThreadID) + " // " + s.preview.Source + " // " + contextAge(s.preview) + lines := []string{ansi.Truncate(header, max(width, 1), "")} + if (s.preview.Kind == codex.SessionContextApproval || s.preview.Kind == codex.SessionContextQuestion) && m.monitorApprovalToken() == "" { + lines = append(lines, ansi.Truncate(i18n.Text("REPLY IN CODEX"), max(width, 1), "")) + } + return append(lines, strings.Split(ansi.Hardwrap(codex.SanitizeSessionContext(s.preview.Text), max(width, 1), true), "\n")...) +} + +func (m Model) renderMonitorContextDetail(width, height int, colors palette) string { + lines := m.contextDetailLines(max(width-4, 1)) + rows := max(height-2, 1) + controls := "" + if m.monitorApprovalControls(width, height) { + a, b := m.monitorApprovalLabels() + controls = m.renderContextAction("approve", a, colors) + strings.Repeat(" ", monitorApprovalSlotWidth()-lipgloss.Width(a)+2) + m.renderContextAction("decline", b, colors) + } else if m.monitorApprovalNotice != "" { + controls = colors.label().Render(ansi.Truncate(m.monitorApprovalNotice, max(width-4, 1), "")) + } + textRows := rows + if controls != "" { + textRows-- + } + start := min(max(m.monitorContextScroll, 0), max(len(lines)-textRows, 0)) + end := min(start+textRows, len(lines)) + for i := start; i < end; i++ { + lines[i] = colors.label().Render(lines[i]) + } + body := strings.Join(lines[start:end], "\n") + if controls != "" { + body = controls + "\n" + body + } + return frameSizedWithTitleAction(width, rows, i18n.Text("SESSION CONTEXT"), m.renderContextAction("close", monitorDismissLabel, colors), body, colors.primary, colors) +} + +func (m *Model) toggleMonitorContext() { + m.monitorApprovalConfirm = "" + m.monitorApprovalNotice = "" + m.monitorContextHidden = !m.monitorContextHidden + m.monitorContextDetail = "" + m.monitorContextHover = "" + m.monitorContextScroll = 0 + m.persistPreferences() +} + +func (m *Model) openMonitorContext(id string) { + if m.monitorContextHidden { + return + } + for _, s := range m.monitorSessionData { + if s.id == id && s.preview.Text != "" && m.monitorSessionVisible(s) { + m.monitorApprovalConfirm = "" + m.monitorApprovalNotice = "" + m.monitorContextDetail = id + m.monitorContextScroll = 0 + return + } + } +} + +func (m *Model) scrollMonitorContext(delta int) { + g := m.dashboardLayout() + rows := max(g.meterHeight-2, 1) + if m.monitorApprovalControls(g.contentWidth, g.meterHeight) || m.monitorApprovalNotice != "" { + rows-- + } + limit := max(len(m.contextDetailLines(max(g.contentWidth-4, 1)))-rows, 0) + m.monitorContextScroll = min(max(m.monitorContextScroll+delta, 0), limit) +} + +func (m Model) updateMonitorContextKey(key string) (Model, tea.Cmd, bool) { + if key == "h" { + m.toggleMonitorContext() + return m, nil, true + } + if m.monitorContextDetail != "" && !m.monitorContextHidden { + switch key { + case "esc", "x", "enter", "i": + m.monitorContextDetail = "" + return m, nil, true + case "up": + m.scrollMonitorContext(-1) + case "down": + m.scrollMonitorContext(1) + case "pgup": + m.scrollMonitorContext(-max(m.dashboardLayout().meterHeight-4, 1)) + case "pgdown": + m.scrollMonitorContext(max(m.dashboardLayout().meterHeight-4, 1)) + case "q", "ctrl+c", "tab", "shift+tab": + m.monitorContextDetail = "" + return m, nil, false + default: + return m, nil, true + } + return m, nil, true + } + if key == "i" || key == "enter" { + id := m.monitorSelectedID + if id == "" { + for _, s := range m.monitorSessionData { + if m.monitorSessionVisible(s) { + id = s.id + break + } + } + } + m.openMonitorContext(id) + return m, nil, true + } + return m, nil, false +} + +func (m Model) monitorContextAt(x, y int) string { + if m.meterView != viewMonitor || m.loading && len(m.snapshot.Meters()) == 0 { + return "" + } + g := m.dashboardLayout() + x -= 2 + y -= g.meterY + if m.monitorContextDetail != "" && !m.monitorContextHidden { + if y == 1 && m.monitorApprovalControls(g.contentWidth, g.meterHeight) { + a, b := m.monitorApprovalLabels() + if x >= 2 && x < 2+lipgloss.Width(a) { + return "approve" + } + if x >= 4+monitorApprovalSlotWidth() && x < 4+monitorApprovalSlotWidth()+lipgloss.Width(b) { + return "decline" + } + } + if r, ok := contextActionRect(g.contentWidth, 0, monitorDismissLabel); ok && r.contains(x, y) { + return "close" + } + return "" + } + a := layoutMonitorArea(g.contentWidth, g.meterHeight) + if len(m.monitorSessionData) > 0 && a.readoutWidth >= 16 { + if r, ok := contextActionRect(a.readoutWidth, 0, m.monitorPrivacyLabel()); ok && r.contains(x, y) { + return "privacy" + } + } + if m.monitorContextHidden { + return "" + } + sessions, heights, _ := m.monitorSessionPage(a.graphHeight) + rowY := a.topHeight + a.gap - 1 + for i, s := range sessions { + mw, cw, gw := contextColumns(a.width, s) + if cw == 0 { + cw = gw + } + if s.preview.Text != "" { + if r, ok := contextActionRect(cw, rowY, monitorContextInfo); ok { + r.x += mw + 1 + if r.contains(x, y) { + return s.id + } + } + } + rowY += heights[i] + } + return "" +} + +func (m Model) updateMonitorContextMouse(msg tea.MouseMsg) (Model, tea.Cmd, bool) { + mouse := msg.Mouse() + _, click := msg.(tea.MouseClickMsg) + m.monitorContextHover = m.monitorContextAt(mouse.X, mouse.Y) + if click && mouse.Button == tea.MouseLeft && m.monitorContextHover != "" { + switch m.monitorContextHover { + case "approve", "decline": + return m.monitorApprovalAction(m.monitorContextHover) + case "privacy": + m.toggleMonitorContext() + case "close": + m.monitorContextDetail = "" + default: + m.openMonitorContext(m.monitorContextHover) + } + return m, nil, true + } + if m.monitorContextDetail != "" && !m.monitorContextHidden { + g := m.dashboardLayout() + if mouse.Y >= g.meterY && mouse.Y < g.meterY+g.meterHeight { + switch mouse.Button { + case tea.MouseWheelUp: + m.scrollMonitorContext(-3) + case tea.MouseWheelDown: + m.scrollMonitorContext(3) + } + return m, nil, true + } + } + return m, nil, false +} diff --git a/internal/ui/monitor_context_test.go b/internal/ui/monitor_context_test.go new file mode 100644 index 0000000..f5a0587 --- /dev/null +++ b/internal/ui/monitor_context_test.go @@ -0,0 +1,163 @@ +package ui + +import ( + "encoding/json" + "strings" + "testing" + "time" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/charmbracelet/x/ansi" + "github.com/merefield/codexometer/internal/codex" +) + +func contextTestModel() Model { + m := Model{width: 120, height: 40, meterView: viewMonitor, snapshot: codex.DemoSnapshot(), monitorState: monitorRunning} + for _, id := range []string{"root-one", "root-two", "root-three"} { + m.monitorSessionData = append(m.monitorSessionData, monitorSession{id: id, active: true, displayed: true, preview: codex.SessionContext{Kind: codex.SessionContextReply, Text: "Finished updating the documentation.\n次の手順を選んでください。", ThreadID: id, Source: "LOCAL", At: time.Now()}, samples: []monitorSample{{intervalTokens: 100}}}) + } + return m +} + +func TestMonitorContextResponsiveHitTargets(t *testing.T) { + for _, size := range []struct{ w, h int }{{40, 16}, {60, 24}, {80, 24}, {100, 30}, {120, 40}, {180, 50}} { + m := contextTestModel() + m.width, m.height = size.w, size.h + for _, kind := range []codex.SessionContextKind{codex.SessionContextReply, codex.SessionContextActivity, codex.SessionContextQuestion} { + for i := range m.monitorSessionData { + m.monitorSessionData[i].preview.Kind = kind + } + out := m.render() + if lipgloss.Width(out) > size.w || lipgloss.Height(out) > size.h { + t.Fatalf("overflow %dx%d: %dx%d", size.w, size.h, lipgloss.Width(out), lipgloss.Height(out)) + } + lines := strings.Split(ansi.Strip(out), "\n") + found := 0 + for y, line := range lines { + for from := 0; from < len(line); { + n := strings.Index(line[from:], monitorContextInfo) + if n < 0 { + break + } + n += from + x := lipgloss.Width(line[:n]) + found++ + for offset := 0; offset < 3; offset++ { + if id := m.monitorContextAt(x+offset, y); id == "" { + t.Fatalf("info miss %dx%d at %d,%d layout=%+v\n%s", size.w, size.h, x+offset, y, m.dashboardLayout(), ansi.Strip(out)) + } + } + updated, _ := m.Update(tea.MouseClickMsg(tea.Mouse{X: x, Y: y, Button: tea.MouseLeft})) + detail := updated.(Model) + if detail.monitorContextDetail == "" { + t.Fatal("click failed") + } + modal := detail.render() + if lipgloss.Width(modal) > size.w || lipgloss.Height(modal) > size.h { + t.Fatal("detail overflow") + } + from = n + 3 + } + } + g := m.dashboardLayout() + a := layoutMonitorArea(g.contentWidth, g.meterHeight) + visible, _, _ := m.monitorSessionPage(a.graphHeight) + if found != len(visible) { + t.Fatalf("expected %d info buttons, got %d at %dx%d", len(visible), found, size.w, size.h) + } + } + } +} + +func TestMonitorContextPrivacyAndModalIsolation(t *testing.T) { + m := contextTestModel() + m.openMonitorContext("root-one") + m.monitorSessionData[0].preview.Text = strings.Repeat("private context\n", 100) + updated, _ := m.Update(key('h')) + m = updated.(Model) + if !m.monitorContextHidden || m.monitorContextDetail != "" || strings.Contains(m.render(), "private context") { + t.Fatal("privacy leak") + } + updated, _ = m.Update(key('i')) + m = updated.(Model) + if m.monitorContextDetail != "" { + t.Fatal("opened hidden context") + } + updated, _ = m.Update(key('h')) + m = updated.(Model) + m.openMonitorContext("root-one") + updated, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyPgDown}) + m = updated.(Model) + if m.monitorContextScroll == 0 { + t.Fatal("detail did not scroll") + } + updated, _ = m.Update(key('s')) + m = updated.(Model) + if m.monitorState != monitorRunning { + t.Fatal("modal activated underlying reset") + } + updated, cmd := m.Update(tea.KeyPressMsg{Code: tea.KeyEscape}) + m = updated.(Model) + if m.monitorContextDetail != "" || cmd != nil { + t.Fatal("escape quit instead of closing") + } + updated, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + m = updated.(Model) + if m.monitorContextDetail != "root-one" { + t.Fatal("keyboard open failed") + } + updated, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyTab}) + m = updated.(Model) + if m.meterView == viewMonitor || m.monitorContextDetail != "" { + t.Fatal("tab navigation left stale modal") + } +} + +func TestMonitorContextPrivacyPersistsWithoutText(t *testing.T) { + m := contextTestModel() + store := &memoryPreferenceStore{} + m.preferenceStore = store + m.toggleMonitorContext() + if !store.preferences.HideSessionContext { + t.Fatal("privacy not saved") + } + saved, err := json.Marshal(store.preferences) + if err != nil || strings.Contains(string(saved), "Finished") || strings.Contains(string(saved), "root-one") { + t.Fatalf("unexpected stored content: %s", saved) + } + next := NewWithPreferences(nil, time.Minute, store) + if !next.monitorContextHidden { + t.Fatal("privacy not restored") + } +} + +func TestMonitorContextPrivacyAndDismissRenderedTargets(t *testing.T) { + for _, width := range []int{40, 60, 80, 120, 180} { + m := contextTestModel() + m.width = width + for _, hidden := range []bool{false, true} { + m.monitorContextHidden = hidden + for y, line := range strings.Split(ansi.Strip(m.render()), "\n") { + if idx := strings.Index(line, m.monitorPrivacyLabel()); idx >= 0 { + x := lipgloss.Width(line[:idx]) + for n := 0; n < lipgloss.Width(m.monitorPrivacyLabel()); n++ { + if m.monitorContextAt(x+n, y) != "privacy" { + t.Fatal("privacy button miss") + } + } + updated, _ := m.Update(tea.MouseClickMsg(tea.Mouse{X: x, Y: y, Button: tea.MouseLeft})) + if updated.(Model).monitorContextHidden == hidden { + t.Fatal("privacy click failed") + } + } + if idx := strings.Index(line, monitorDismissLabel); idx >= 0 { + x := lipgloss.Width(line[:idx]) + if _, ok := m.monitorSessionDismissAt(x, y); !ok { + t.Fatalf("dismiss miss at width %d", width) + } + } + } + } + } +} diff --git a/internal/ui/preferences.go b/internal/ui/preferences.go index 1f9297b..8390c91 100644 --- a/internal/ui/preferences.go +++ b/internal/ui/preferences.go @@ -12,10 +12,11 @@ import ( // Operational, account, quota-estimation, session, and token-event data are // never stored. type Preferences struct { - Theme string `json:"theme,omitempty"` - QuotaView string `json:"quotaView,omitempty"` - BenchmarkFilter string `json:"benchmarkFilter,omitempty"` - BenchmarkRank string `json:"benchmarkRank,omitempty"` + HideSessionContext bool `json:"hideSessionContext,omitempty"` + Theme string `json:"theme,omitempty"` + QuotaView string `json:"quotaView,omitempty"` + BenchmarkFilter string `json:"benchmarkFilter,omitempty"` + BenchmarkRank string `json:"benchmarkRank,omitempty"` } type PreferenceStore interface { @@ -75,6 +76,7 @@ func NewWithPreferences(fetcher Fetcher, refreshEvery time.Duration, store Prefe } func (m *Model) applyPreferences(preferences Preferences) { + m.monitorContextHidden = preferences.HideSessionContext if theme, ok := themePreferenceIDs[preferences.Theme]; ok { m.theme = theme } @@ -95,10 +97,11 @@ func (m Model) persistPreferences() { return } _ = m.preferenceStore.Save(Preferences{ - Theme: themePreferenceNames[m.theme], - QuotaView: quotaViewPreferenceNames[m.selectedQuotaView()], - BenchmarkFilter: benchmarkFilterPreferenceNames[m.benchmarkFilter], - BenchmarkRank: benchmarkRankPreferenceNames[m.benchmarkRankMode], + HideSessionContext: m.monitorContextHidden, + Theme: themePreferenceNames[m.theme], + QuotaView: quotaViewPreferenceNames[m.selectedQuotaView()], + BenchmarkFilter: benchmarkFilterPreferenceNames[m.benchmarkFilter], + BenchmarkRank: benchmarkRankPreferenceNames[m.benchmarkRankMode], }) } diff --git a/intro-post.md b/intro-post.md index d2a1ca4..9121633 100644 --- a/intro-post.md +++ b/intro-post.md @@ -36,7 +36,7 @@ API-equivalent figures are workload-dependent estimates, not your subscription The reset button normally appears only when Codex reports an available reset and a quota window is at least **80% consumed**. Change that threshold with `--reset-threshold 60`. Click once to reveal confirmation, then again within ten seconds to redeem; `Esc` cancels. A reset refreshes eligible quota and changes the weekly reset schedule—it does not stack additional allowance. -Ordinary Codex CLI sessions work out of the box. For the best Monitor feedback, connect your CLI sessions through a shared local Codex app-server: Codexometer can distinguish **INPUT NEEDED** from **APPROVAL NEEDED** and use positively matched resolved-model events for more accurate pricing. Without that setup, it falls back to local session signals and a cautious **CHECK SESSION** inactivity prompt, not a guessed approval alert. See the [recommended setup](https://github.com/merefield/codexometer#recommended-codex-cli-setup). +Ordinary Codex CLI sessions work out of the box. For the best Monitor feedback, connect your CLI sessions through a shared local Codex app-server: Codexometer can distinguish **INPUT NEEDED** from **APPROVAL NEEDED**, show command-approval details with confirmed **Approve once** and **Decline** controls for supported requests, and use positively matched resolved-model events for more accurate pricing. Without that setup, it falls back to local session signals and a cautious **CHECK SESSION** inactivity prompt, not a guessed approval alert. See the [recommended setup](https://github.com/merefield/codexometer#recommended-codex-cli-setup). ## Make it yours @@ -91,7 +91,7 @@ Prefer a non-interactive run? `codexometer --digbench-game P-1` runs a named gam ## Privacy, authentication, and cost -Quota monitoring uses the prevailing Codex login and does not inspect ordinary conversation content. Detailed interaction capture is restricted to isolated benchmark turns created by Codexometer; ordinary Codex session messages remain excluded. +Quota monitoring uses the prevailing Codex login. Monitor can show a bounded last-reply, activity, question, or approval preview beside each session, with an `[i]` detail view. Excerpts remain in memory and do not trigger another model call; `h` hides them, and that display preference is remembered. Questions and unsupported approvals are answered in Codex. Supported shared-server command approvals require explicit clicks; no persistent permissions are granted. Full interaction capture remains restricted to isolated benchmark turns created by Codexometer; Monitor does not retain user prompts, reasoning, or arbitrary tool output. Benchmark transcripts are bounded and sanitized. Credentials, request headers, known runtime identifiers, temporary paths, terminal controls, Codex reasoning, and unrelated local session content are not retained in the detail view. diff --git a/main.go b/main.go index 16e7849..6e0eddf 100644 --- a/main.go +++ b/main.go @@ -19,18 +19,19 @@ import ( ) type demoFetcher struct { - mu sync.Mutex - snapshot codex.Snapshot - pendingQuota int - suppressUsage bool - lifetimeTokens int64 - apiEqUSD float64 - apiEqCalls int64 - eventSequence uint64 - alphaCalls []codex.LiveModelCall - alphaTurns []codex.LiveTurnTiming - bravoCalls []codex.LiveModelCall - bravoTurns []codex.LiveTurnTiming + approvalDecision string + mu sync.Mutex + snapshot codex.Snapshot + pendingQuota int + suppressUsage bool + lifetimeTokens int64 + apiEqUSD float64 + apiEqCalls int64 + eventSequence uint64 + alphaCalls []codex.LiveModelCall + alphaTurns []codex.LiveTurnTiming + bravoCalls []codex.LiveModelCall + bravoTurns []codex.LiveTurnTiming } func (d *demoFetcher) FetchAccountUsage(context.Context) (codex.AccountUsage, error) { @@ -116,21 +117,48 @@ func (d *demoFetcher) FetchTokenUsage(context.Context) (codex.LiveUsageSnapshot, } } alphaTokens := d.lifetimeTokens * 3 / 5 + attention := codex.SessionAttentionApproval + preview := codex.SessionContext{Kind: codex.SessionContextApproval, Text: "DEMO ONLY — no command will run.\nAllow pushing the documentation update?\nCommand: git push origin main\nDirectory: /projects/alpha", ThreadID: "019d-demo-a1b2c", Source: "DEMO", At: time.Now().Add(-time.Minute), ApprovalToken: "demo-command"} + if d.approvalDecision != "" { + attention = codex.SessionAttentionNone + preview.Kind = codex.SessionContextReply + preview.ApprovalToken = "" + preview.Text = "Simulated decision: " + d.approvalDecision + ". No command was executed." + } return codex.LiveUsageSnapshot{ TotalTokens: d.lifetimeTokens, APIEqUSD: d.apiEqUSD, APIEqPricedCalls: d.apiEqCalls, LastActivity: time.Now(), SessionCount: 2, CodexStatusKnown: true, CodexUp: true, CodexWorking: true, Sessions: []codex.LiveUsageSession{ {ID: "019d-demo-a1b2c", WorkingDirectory: "/projects/alpha", TotalTokens: alphaTokens, LastActivity: time.Now(), AgentCount: 2, Active: true, - Attention: codex.SessionAttentionApproval, ModelCalls: append([]codex.LiveModelCall(nil), d.alphaCalls...), TurnTimings: append([]codex.LiveTurnTiming(nil), d.alphaTurns...)}, + Attention: attention, Context: preview, ModelCalls: append([]codex.LiveModelCall(nil), d.alphaCalls...), TurnTimings: append([]codex.LiveTurnTiming(nil), d.alphaTurns...)}, {ID: "019d-demo-d4e5f", WorkingDirectory: "/projects/bravo", TotalTokens: d.lifetimeTokens - alphaTokens, LastActivity: time.Now(), Active: true, - Attention: codex.SessionAttentionInput, ModelCalls: append([]codex.LiveModelCall(nil), d.bravoCalls...), TurnTimings: append([]codex.LiveTurnTiming(nil), d.bravoTurns...)}, + Attention: codex.SessionAttentionInput, Context: codex.SessionContext{Kind: codex.SessionContextReply, Text: "Pushed directly to main.\nIncludes both README and intro-post updates.", ThreadID: "019d-demo-d4e5f", Source: "DEMO", At: time.Now().Add(-2 * time.Minute)}, ModelCalls: append([]codex.LiveModelCall(nil), d.bravoCalls...), TurnTimings: append([]codex.LiveTurnTiming(nil), d.bravoTurns...)}, }, }, nil } func (d *demoFetcher) BenchmarkCombinationCount(context.Context) (int, error) { return 2, nil } +func (d *demoFetcher) SessionApprovalPending(token string) bool { + d.mu.Lock() + defer d.mu.Unlock() + return token == "demo-command" && d.approvalDecision == "" +} + +func (d *demoFetcher) RespondSessionApproval(ctx context.Context, token, decision string) error { + d.mu.Lock() + defer d.mu.Unlock() + if err := ctx.Err(); err != nil { + return err + } + if token != "demo-command" || d.approvalDecision != "" || (decision != "accept" && decision != "decline") { + return fmt.Errorf("invalid or resolved demo approval") + } + d.approvalDecision = decision + return nil +} + func (d *demoFetcher) RunBenchmarkSuite(ctx context.Context, tasks []codex.BenchmarkTaskID, emit func(codex.BenchmarkEvent)) { d.RunBenchmarkSuiteScoped(ctx, tasks, d.demoBenchmarkPlan().AllScope(), emit) } diff --git a/main_test.go b/main_test.go index 9dd8f84..6928647 100644 --- a/main_test.go +++ b/main_test.go @@ -26,6 +26,31 @@ func TestDemoAccountHistoryMatchesQuotaAccount(t *testing.T) { } } +func TestDemoCommandApprovalIsOneUseSimulation(t *testing.T) { + d := &demoFetcher{} + snapshot, err := d.FetchTokenUsage(context.Background()) + if err != nil { + t.Fatal(err) + } + token := snapshot.Sessions[0].Context.ApprovalToken + if !d.SessionApprovalPending(token) { + t.Fatal("demo approval missing") + } + if err := d.RespondSessionApproval(context.Background(), token, "acceptForSession"); err == nil { + t.Fatal("demo accepted persistent grant") + } + if err := d.RespondSessionApproval(context.Background(), token, "accept"); err != nil { + t.Fatal(err) + } + if d.SessionApprovalPending(token) { + t.Fatal("demo approval reused") + } + snapshot, _ = d.FetchTokenUsage(context.Background()) + if snapshot.Sessions[0].Attention != codex.SessionAttentionNone || !strings.Contains(snapshot.Sessions[0].Context.Text, "No command was executed") { + t.Fatal("demo outcome missing") + } +} + func TestRunVersion(t *testing.T) { for _, flag := range []string{"-v", "--version"} { var stdout, stderr bytes.Buffer From e645347ca50e2e876e94dc58983788d7f0b3d20f Mon Sep 17 00:00:00 2001 From: merefield Date: Sun, 6 Sep 2026 16:13:07 +0100 Subject: [PATCH 2/9] FEAT: complete Monitor interactions and session targeting --- README.md | 127 ++++++++-- go.mod | 4 +- go.sum | 8 +- internal/codex/approval_options.go | 84 +++++++ internal/codex/approval_options_test.go | 50 ++++ internal/codex/daemon_status.go | 1 + internal/codex/daemon_status_unix.go | 15 +- internal/codex/live_usage.go | 20 +- internal/codex/live_usage_test.go | 10 +- internal/codex/session_approval.go | 1 + internal/codex/session_approval_test.go | 29 ++- internal/codex/session_approval_unix.go | 16 +- internal/codex/session_approval_unix_test.go | 55 ++++ internal/codex/session_context.go | 23 +- internal/codex/session_context_daemon.go | 56 ++++- internal/codex/session_context_test.go | 33 +++ internal/codex/session_prompt.go | 100 ++++++++ internal/codex/session_prompt_unix.go | 168 +++++++++++++ internal/codex/session_prompt_unix_test.go | 167 +++++++++++++ internal/i18n/locales/de.json | 34 ++- internal/i18n/locales/en-GB.json | 34 ++- internal/i18n/locales/es.json | 34 ++- internal/i18n/locales/fr.json | 34 ++- internal/i18n/locales/it.json | 34 ++- internal/i18n/locales/ja.json | 34 ++- internal/i18n/locales/nl.json | 34 ++- internal/i18n/locales/ru.json | 34 ++- internal/i18n/locales/zh-Hans.json | 34 ++- internal/ui/localisation_test.go | 2 + internal/ui/model.go | 24 +- internal/ui/monitor.go | 26 +- internal/ui/monitor_approval.go | 155 ++++++++++-- internal/ui/monitor_approval_test.go | 129 +++++++++- internal/ui/monitor_context.go | 159 ++++++++---- internal/ui/monitor_context_modes.go | 205 +++++++++++++++ internal/ui/monitor_context_modes_test.go | 218 ++++++++++++++++ internal/ui/monitor_context_test.go | 20 +- internal/ui/monitor_prompt.go | 248 +++++++++++++++++++ internal/ui/monitor_prompt_test.go | 191 ++++++++++++++ intro-post.md | 4 +- main.go | 6 +- main_test.go | 4 +- 42 files changed, 2523 insertions(+), 141 deletions(-) create mode 100644 internal/codex/approval_options.go create mode 100644 internal/codex/approval_options_test.go create mode 100644 internal/codex/session_prompt.go create mode 100644 internal/codex/session_prompt_unix.go create mode 100644 internal/codex/session_prompt_unix_test.go create mode 100644 internal/ui/monitor_context_modes.go create mode 100644 internal/ui/monitor_context_modes_test.go create mode 100644 internal/ui/monitor_prompt.go create mode 100644 internal/ui/monitor_prompt_test.go diff --git a/README.md b/README.md index 2e36190..89b4cc6 100644 --- a/README.md +++ b/README.md @@ -310,7 +310,7 @@ telemetry on macOS, Linux, and WSL: the cautious `CHECK SESSION` inactivity fallback. - **Command approval details and controls** — supported live requests show the command, directory and reason in Monitor's `[i]` detail page, with - **Approve once → Confirm approval** and **Decline** buttons. Incomplete or + buttons matching Codex's offered decisions, with confirmation for grants. Incomplete or unsupported requests still need answering in Codex. - **Resolved-model API equivalents** — live model-reroute and response-usage events let Codexometer price a positively matched call using the model that @@ -488,8 +488,9 @@ app-server subscription for already-loaded thread IDs. From that stream it retains runtime flags, model-reroute/token-usage correlations, and bounded context from assistant messages and input/approval requests. Resolved requests and new turns clear stale pending context. This connection never starts a turn -or answers a question. It can send an explicit, user-confirmed one-time command -approval or decline from the Monitor detail page (see below). Without a shared daemon, previews use +or answers a question. It can send an explicitly selected command decision, +with confirmation for permission grants, from the Monitor detail page (see below). +Without a shared daemon, previews use available local rollout text rather than fetching full thread histories. Missing request details are left unavailable; there is no extra model call. @@ -511,7 +512,7 @@ codexometer --codex /path/to/codex | `s` | Reset the Monitor baseline, or open Benchmark Scope | | `p` | Pause or resume live monitoring (Monitor view only) | | `h` | Hide/show Monitor context previews and close any open context detail | -| `i` | Open context for the selected Monitor row (or first visible row), or close context detail | +| `i` | Cycle Monitor context: compact → expanded row → full detail → compact; use the selected session, otherwise the latest approval-gated session | | `b` | Run the selected benchmark scope (Benchmark view only) | | `a` | Arm, then confirm, Run All (Benchmark view only) | | `x` | Dismiss the selected Monitor row; close Benchmark detail/Scope, or stop an active suite and retain its incomplete trial | @@ -522,12 +523,12 @@ codexometer --codex /path/to/codex | `f` | Show all, passed, or failed benchmark results | | `w` | Select Weekly in Usage, or cycle Cost, Balanced, and Speed benchmark ranking weights | | `Up` / `Down` | Select a Monitor session row or Benchmark row, or scroll open Benchmark detail | -| `Enter` / `Space` | Open a selected Benchmark result, or toggle a Scope checkbox; `Enter` opens/closes Monitor context | +| `Enter` / `Space` | Open a selected Benchmark result, or toggle a Scope checkbox; `Enter` cycles Monitor context like `i` | | `c` | Select Cumulative in Usage, copy the Benchmark result matrix as Markdown, or copy the complete open run detail | | `l` | Clear accumulated Benchmark results while no suite is running | | `Page Up` / `Page Down` | Page Usage history, Monitor session rows, or Benchmark results | | `q` | Quit | -| `Esc` | Close Monitor context, cancel quota-reset confirmation/dismiss its notice, return from Benchmark detail or Scope; otherwise quit | +| `Esc` | Step back one Monitor context level, cancel quota-reset confirmation/dismiss its notice, return from Benchmark detail or Scope; otherwise quit | | `Ctrl+C` | Quit | The responsive top rail below the account status selects Quota, Monitor, Usage, or @@ -990,27 +991,114 @@ column temporarily. Ordinary busy-session commentary stays collapsed to an `[i]` action on the graph; `CHECK SESSION` can expose its last known activity. With no context available, the original metrics/graph layout remains. -- **LAST REPLY** is the last completed assistant reply, not a new question. A - local completed-turn prompt with a final reply is labelled **TURN COMPLETE**. +- **LAST REPLY** is the last completed assistant reply, not a new question. An + observed local completed-turn prompt is labelled **TURN COMPLETE**. + For shared-server sessions, **TURN COMPLETE** comes from an observed successful + `turn/completed` event followed by idle status, independently of whether reply + text is available or previews are hidden. Failed/interrupted turns and idle + threads without an observed completion do not receive this label. Starting + the next turn clears it; a working linked agent suppresses root completion, + and actual input/approval requests take priority. Attaching after a turn + finished may miss its live completion event; the next completed turn qualifies. - **QUESTION** contains an observed blocking input request and any choices. - **REQUEST** contains an observed approval reason/command when available. - **LAST ACTIVITY** is observed commentary or a command, not proof that input is required. Ages describe the last observed event; paused readings can be stale. -Click `[i]`, or select a row with arrows and press `i`/`Enter`, for a scrollable -detail view. Use arrows, Page Up/Down, or the mouse wheel; close with `[×]`, -`Esc`, `i`, `Enter`, or `x`. Answer questions in Codex itself. +Click a row's `[i]`, or press `i`/`Enter`, to cycle through three presentations: + +1. **Compact:** the normal short preview beside the token graph; no approval buttons. +2. **Expanded:** that session's context occupies the full space previously shared + by its preview and token graph, while telemetry and the other sessions stay + visible. Eligible approval buttons sit below the complete command/request + and source session. If the complete request plus controls cannot fit, an + **OPEN DETAIL** action is offered instead (just `[i]` on very narrow terminals). +3. **Full detail:** the existing scrollable Monitor-area view with pinned buttons. + Use arrows, Page Up/Down, or the mouse wheel to read long requests. Another + `i` or `[i]` click returns directly to compact. When a reply editor is available, + `Enter` focuses it instead of changing presentation. + +`Esc` steps back one level; `[×]` or `x` in full detail returns to expanded. +The initial keyboard target is the explicitly selected session, otherwise the +most recent approval-gated session with context, otherwise the first session +with context. Once expanded, the target is pinned and kept on screen: newer +approvals cannot silently switch the session behind the buttons. Clicking +another row's `[i]` explicitly changes the target. Switching context levels +clears unsubmitted approval confirmations. Hiding previews or dismissing the +target clears its expansion. +When a row is explicitly selected, only that row shows `[i]`, even if it has no +context yet; `Enter`/`i` opens that session rather than falling back to another. +Moving the selection collapses any other expanded row and clears its pending +confirmation. With no selected row, the default targeting described above applies. Outstanding requests take priority over ordinary activity when linked agents share a root row, and the source ID identifies the actual member. +**Replies and follow-ups (shared app-server only):** full detail reserves a +compact prompt at the bottom when a loaded session is idle (including **TURN +COMPLETE**), or has a supported blocking question. Click the input line or press +`Enter` to focus it; type your text, then press `Enter` to submit. Dashboard +hotkeys become ordinary letters while typing. `Esc` leaves the editor without +sending; press it again to return to the expanded row. Cursor editing, Unicode +and bracketed paste are supported; pasted newlines become spaces and never +submit. This is a single-line text editor, not the Codex slash-command UI. + +For a question with multiple parts, `Enter` records each answer locally; only +after the last answer is the complete response sent. Use `↑`/`↓` to select offered +choices. Custom text is accepted only if the question permits it, and secret +answers are masked. Unsupported or ambiguous simultaneous requests remain +**REPLY IN CODEX**. Approval requests retain their explicit decision buttons; +typing is not a substitute for approval. + +An idle follow-up starts a new turn in the displayed root CLI session using its +existing configuration, without changing its model, working directory or +permission policy. A pending question is answered on its exact source thread, +which can be a linked agent. The editor shows the target thread or question. +Drafts are memory-only, bounded to 4,096 characters per answer, and discarded +when leaving detail, hiding context, or when the request/connection changes. +Submitted text is sent to Codex and can become part of its normal session history. +No text is sent automatically; pressing Enter while focused is the send action. + +Capabilities are one-use and connection-bound. Follow-ups recheck the live idle +state immediately before `turn/start`, and pending answers use their original +JSON-RPC request and question IDs. The protocol has no atomic “start only if this +completed turn is still current” condition, so avoid submitting in Codex and +Codexometer simultaneously. Failed or ambiguous sends are never automatically +retried: check Codex first. Local-only, disconnected, busy and very small views +do not offer an editor; use Codex itself in those cases. Codexometer does not +detect the CLI's visual keyboard-focus state or type into its terminal. + **Command approvals (shared app-server only):** the detail page shows the reason, command and working directory. When the approval event omits the command or directory, Codexometer associates it with the preceding command item from the same thread, turn and item. A complete ordinary command request offers clickable -**APPROVE ONCE** and **DECLINE** buttons. Approval requires a second click on -**CONFIRM APPROVAL**; closing the detail page cancels that confirmation. There is -no approval keyboard shortcut and no persistent/session-wide permission grant. -Controls are hidden on terminals too small to fit the confirmation labels. +buttons matching the supported decisions Codex actually offers, in its order: + +- **APPROVE ONCE** grants this command after **CONFIRM APPROVAL**. +- **ALLOW FOR SESSION** grants session-scoped approval after **CONFIRM SESSION + GRANT**; future prompts covered by Codex's session approval cache may run + without asking again. +- **ALWAYS ALLOW PREFIX** applies the exact displayed persistent command-prefix + rule after **CONFIRM PERSISTENT RULE**. Future matching commands may run + without asking again; review the displayed prefix carefully. +- **DECLINE** rejects the command but lets the agent continue the turn. +- **REJECT & STOP TURN** sends Codex's `cancel` decision: reject the command and + interrupt the turn. This is distinct from declining or closing the detail page. + +Each button appears independently; a normal Accept/Cancel prompt no longer +requires a Decline option to enable approval. Unknown decision types are not +actionable. The **OFFERED DECISIONS** line shows bounded, canonical decision +names/types for troubleshooting, not conversation content. Structured +command-prefix grants are validated and their exact payload is returned; no +decision absent from the request can be submitted. For older servers omitting +the decision list, only the legacy Accept/Cancel pair is offered. + +Closing the detail page cancels an unsubmitted confirmation. There are no +approval keyboard shortcuts. Buttons wrap into rows when needed, reserve their +confirmation widths so other targets do not move, and sit in a pinned footer +below the scrolling request text, separated by one blank line when space allows. +Very short terminals omit that spacer first. Controls are hidden if the terminal +cannot fit them with room to read the request. All permission grants require +their own explicit second click; confirming one choice cannot confirm another. Actions are bound to a single pending request on the current connection. They become unavailable when resolved elsewhere, the turn ends, or the connection @@ -1026,13 +1114,20 @@ grants and other unsupported requests remain **REPLY IN CODEX**. The complete eligible request is available in the scrollable detail, not just the compact two-line preview. Unsupported requests may have only a bounded excerpt. +When controls are unavailable, the detail page explains why beside +**REPLY IN CODEX**: for example, missing command/directory or request identity, +additional permissions, network/file-change approval, no supported decisions, +truncated or sanitised text, local-only observation, or a resolved/disconnected +request. Eligible requests on small terminals instead explain that the terminal +must be enlarged. These diagnostics do not relax any approval safeguards. + The readout's `[H:HIDE]`/`[H:SHOW]` button or `h` toggles all previews; the choice survives restarts. No excerpt is saved. Each retained excerpt is capped at 4,096 Unicode characters; startup reads only a bounded 256 KiB rollout tail, so older context can be unavailable. Terminal escapes and control/bidirectional-formatting characters are stripped. Previews remain in their original language and are not LLM-generated summaries. `--demo` includes example context and a simulated -one-time command approval without accessing a real conversation or executing +command approval choices without accessing a real conversation or executing any command; restart the demo to reset its approval. ### Saved presentation preferences diff --git a/go.mod b/go.mod index b33ca00..f7bf45b 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/merefield/codexometer go 1.26.6 require ( + charm.land/bubbles/v2 v2.2.1 charm.land/bubbletea/v2 v2.0.8 charm.land/lipgloss/v2 v2.0.6 github.com/charmbracelet/x/ansi v0.11.8 @@ -13,6 +14,7 @@ require ( ) require ( + github.com/atotto/clipboard v0.1.4 // indirect github.com/charmbracelet/colorprofile v0.4.3 // indirect github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886 // indirect github.com/charmbracelet/x/term v0.2.2 // indirect @@ -21,7 +23,7 @@ require ( github.com/clipperhouse/displaywidth v0.11.0 // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/lucasb-eyer/go-colorful v1.4.1 // indirect - github.com/mattn/go-runewidth v0.0.24 // indirect + github.com/mattn/go-runewidth v0.0.27 // indirect 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 diff --git a/go.sum b/go.sum index cc7272f..90c586e 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,11 @@ +charm.land/bubbles/v2 v2.2.1 h1:Fq1+qm5hV6GkvzLQDhCBpXXE5tLgvh1PRriCLwSvIQU= +charm.land/bubbles/v2 v2.2.1/go.mod h1:wdMgn+sje1KNXdwFizIWjbf328fIUBxqEmJ/vYPo8yc= charm.land/bubbletea/v2 v2.0.8 h1:SxTJMhCAI3lbPmy4SgX5LWZ24AdINr4I6UEqzZvYJuY= charm.land/bubbletea/v2 v2.0.8/go.mod h1:2SkdgoTXluXJHOUwAoRlRXF/28vklb1rFl6GcgV1/ss= charm.land/lipgloss/v2 v2.0.6 h1:EaGKeuA8FvF+v2BT5VmZd2LoYLaMZJXA5n34th8nCIQ= charm.land/lipgloss/v2 v2.0.6/go.mod h1:ipDDJNSGa1hlwDtSfW1s2/xR8Vdhbut4PXh2zEKZd0Q= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o= github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w= github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= @@ -28,8 +32,8 @@ github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aN github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/lucasb-eyer/go-colorful v1.4.1 h1:1EO+WB73+EH8EVbzlrG3KLAfEypQWVHIBqlTf+2hNss= github.com/lucasb-eyer/go-colorful v1.4.1/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= -github.com/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/ay2DU= -github.com/mattn/go-runewidth v0.0.24/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/mattn/go-runewidth v0.0.27 h1:Feg/Oou5zI/wnpgDF6omIU0OokC9GxLC/WRknhVlIR0= +github.com/mattn/go-runewidth v0.0.27/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= diff --git a/internal/codex/approval_options.go b/internal/codex/approval_options.go new file mode 100644 index 0000000..b5b846e --- /dev/null +++ b/internal/codex/approval_options.go @@ -0,0 +1,84 @@ +package codex + +import ( + "bytes" + "encoding/json" + "strings" +) + +// Fixed-size, immutable value snapshots keep preview comparisons deterministic. +// Value selects an advertised option; Wire is the validated server payload. +type ApprovalOption struct{ Kind, Value, Wire, Detail string } + +func (o ApprovalOption) GrantsPermission() bool { + return o.Kind == "accept" || o.Kind == "acceptForSession" || o.Kind == "acceptWithExecpolicyAmendment" +} + +func commandApprovalOptions(raw []json.RawMessage) ([8]ApprovalOption, string) { + var out [8]ApprovalOption + // Older servers omit this optional list. Keep a minimal legacy fallback; + // never invent a persistent or session grant. + if raw == nil { + raw = []json.RawMessage{json.RawMessage(`"accept"`), json.RawMessage(`"cancel"`)} + } + var names []string + n := 0 + for _, r := range raw { + var name string + var o ApprovalOption + if json.Unmarshal(r, &name) == nil { + switch name { + case "accept", "acceptForSession", "decline", "cancel": + wire, _ := json.Marshal(name) + o = ApprovalOption{Kind: name, Value: name, Wire: string(wire)} + default: + name = "unsupported-string" + } + } else { + name = "unsupported-object" + var object map[string]json.RawMessage + if json.Unmarshal(r, &object) == nil && len(object) == 1 { + if payload, ok := object["acceptWithExecpolicyAmendment"]; ok { + name = "acceptWithExecpolicyAmendment" + var amendment struct { + Prefix []string `json:"execpolicy_amendment"` + } + decoder := json.NewDecoder(bytes.NewReader(payload)) + decoder.DisallowUnknownFields() + if decoder.Decode(&amendment) == nil && len(amendment.Prefix) > 0 { + prefix, _ := json.Marshal(amendment.Prefix) + valid := len(prefix) <= 1024 + for _, part := range amendment.Prefix { + valid = valid && part != "" && SanitizeSessionContext(part) == part + } + if valid { + wire, _ := json.Marshal(map[string]any{"acceptWithExecpolicyAmendment": amendment}) + o = ApprovalOption{Kind: name, Value: string(wire), Wire: string(wire), Detail: string(prefix)} + } else { + name = "unsupported-execpolicy" + } + } else { + name = "unsupported-execpolicy" + } + } + } + } + if len(names) < 16 { + names = append(names, name) + } + if o.Kind != "" && n < len(out) { + duplicate := false + for _, old := range out { + duplicate = duplicate || old.Value == o.Value + } + if !duplicate { + out[n] = o + n++ + } + } + } + if len(raw) > 16 { + names = append(names, "…") + } + return out, strings.Join(names, ", ") +} diff --git a/internal/codex/approval_options_test.go b/internal/codex/approval_options_test.go new file mode 100644 index 0000000..781dae0 --- /dev/null +++ b/internal/codex/approval_options_test.go @@ -0,0 +1,50 @@ +package codex + +import ( + "encoding/json" + "strings" + "testing" +) + +func TestAdvertisedApprovalChoices(t *testing.T) { + for _, tc := range []struct { + raw string + kinds []string + }{ + {`["accept","cancel"]`, []string{"accept", "cancel"}}, + {`["cancel"]`, []string{"cancel"}}, + {`["acceptForSession","decline"]`, []string{"acceptForSession", "decline"}}, + {`null`, []string{"accept", "cancel"}}, + {`[]`, nil}, + {`["future",{"other":{}},"cancel","cancel"]`, []string{"cancel"}}, + {`[{"acceptWithExecpolicyAmendment":{"execpolicy_amendment":["git","status"]}},"cancel"]`, []string{"acceptWithExecpolicyAmendment", "cancel"}}, + {`[{"acceptWithExecpolicyAmendment":{"execpolicy_amendment":[],"future":true}}]`, nil}, + {`[{"acceptWithExecpolicyAmendment":{"execpolicy_amendment":["pwd\u001b[31m"]}}]`, nil}, + } { + var raw []json.RawMessage + if err := json.Unmarshal([]byte(tc.raw), &raw); err != nil { + t.Fatal(err) + } + options, summary := commandApprovalOptions(raw) + for i, o := range options { + want := "" + if i < len(tc.kinds) { + want = tc.kinds[i] + } + if o.Kind != want { + t.Fatalf("%s option %d: %+v want %s", tc.raw, i, o, want) + } + } + if len(tc.kinds) > 0 && summary == "" { + t.Fatal("missing decision metadata") + } + } + _, c := approvalFixture(t, map[string]any{"availableDecisions": []string{"accept", "cancel"}}) + if c.ApprovalToken == "" || c.ApprovalBlocked != "" { + t.Fatal("standard accept/cancel prompt still blocked") + } + _, c = approvalFixture(t, map[string]any{"availableDecisions": []any{map[string]any{"acceptWithExecpolicyAmendment": map[string]any{"execpolicy_amendment": []string{"git", "status"}}}}}) + if c.ApprovalToken == "" || !strings.Contains(c.Text, `Persistent command-prefix rule: ["git","status"]`) { + t.Fatal("persistent grant lacks displayed rule") + } +} diff --git a/internal/codex/daemon_status.go b/internal/codex/daemon_status.go index 4d107f6..329a45e 100644 --- a/internal/codex/daemon_status.go +++ b/internal/codex/daemon_status.go @@ -10,6 +10,7 @@ const ( sessionRuntimeIdle sessionRuntimeInput sessionRuntimeApproval + sessionRuntimeComplete ) type sessionStatusProvider interface { diff --git a/internal/codex/daemon_status_unix.go b/internal/codex/daemon_status_unix.go index be917da..882aca0 100644 --- a/internal/codex/daemon_status_unix.go +++ b/internal/codex/daemon_status_unix.go @@ -127,6 +127,9 @@ func (p *daemonStatusProvider) statusSnapshotLocked(threadIDs []string) sessionD statuses := make(map[string]sessionRuntimeStatus, len(threadIDs)) for _, threadID := range threadIDs { if status, ok := p.statuses[threadID]; ok { + if state := p.contexts[threadID]; status == sessionRuntimeIdle && state != nil && state.completed { + status = sessionRuntimeComplete + } statuses[threadID] = status } } @@ -287,7 +290,16 @@ func (p *daemonStatusProvider) isSubscribed(threadID string) bool { func (p *daemonStatusProvider) request(ctx context.Context, method string, params any, target any) error { p.mu.Lock() connection := p.connection - if connection == nil { + p.mu.Unlock() + return p.requestOn(ctx, connection, method, params, target) +} + +func (p *daemonStatusProvider) requestOn(ctx context.Context, connection *websocket.Conn, method string, params any, target any) error { + if err := ctx.Err(); err != nil { + return err + } + p.mu.Lock() + if connection == nil || p.connection != connection { p.mu.Unlock() return errors.New("daemon connection is closed") } @@ -373,6 +385,7 @@ func (p *daemonStatusProvider) readLoop(connection *websocket.Conn) { p.contexts = map[string]*daemonContextState{} } daemonContextEvent(p.contexts, envelope.Method, envelope.ID, envelope.Params, time.Now()) + p.promptLifecycleLocked(envelope.Method, envelope.Params) p.mu.Unlock() p.handleNotification(envelope.Method, envelope.Params) } diff --git a/internal/codex/live_usage.go b/internal/codex/live_usage.go index c3ff549..cd7dd0d 100644 --- a/internal/codex/live_usage.go +++ b/internal/codex/live_usage.go @@ -62,6 +62,7 @@ type LiveUsageSession struct { // SessionAttention describes why a local Codex session may need the user. // Input and approval are definite signals; Check is deliberately cautious and // means only that an open fallback-observed session has remained quiet. +// Complete is informational: an observed completed turn, not an input request. type SessionAttention int const ( @@ -69,6 +70,7 @@ const ( SessionAttentionInput SessionAttentionApproval SessionAttentionCheck + SessionAttentionComplete ) // LiveModelCall is the small, content-free usage pulse persisted after one @@ -1131,10 +1133,17 @@ func (r *LiveUsageReader) sessionSnapshots(now time.Time, liveWriters map[string exactWorking := exact && exactStatus == sessionRuntimeWorking localWorking := !exact && writerActive && cursor.attention == sessionAttentionWorking attention := sessionAttention(cursor.attention, writerActive, writerLocksSupported, quietFor, exactStatus, exact) + if !exact && attention == SessionAttentionInput && cursor.attention == sessionAttentionIdle && cursor.preview.Kind == SessionContextReply { + attention = SessionAttentionComplete + } if !active && !exactWorking && attention == SessionAttentionNone && cursor.observedTokens == 0 && len(cursor.modelCalls) == 0 && len(cursor.turnTimings) == 0 { continue } rootID, unattributed := rolloutRoot(cursor, byID) + // A child finishing is not proof that its root conversation finished. + if attention == SessionAttentionComplete && cursor.threadID != rootID { + attention = SessionAttentionNone + } group := groups[rootID] if group == nil { group = &LiveUsageSession{ID: rootID, Unattributed: unattributed} @@ -1188,6 +1197,9 @@ func (r *LiveUsageReader) sessionSnapshots(now time.Time, liveWriters map[string activeCount := 0 anyWorking := false for _, session := range groups { + if session.Attention == SessionAttentionComplete && groupWorking[session.ID] { + session.Attention = SessionAttentionNone + } if session.Attention == SessionAttentionCheck && groupHasFreshActivity[session.ID] { session.Attention = SessionAttentionNone } @@ -1214,6 +1226,8 @@ func (r *LiveUsageReader) sessionSnapshots(now time.Time, liveWriters map[string func sessionAttention(state sessionAttentionState, writerActive, writerLocksSupported bool, quietFor time.Duration, exactStatus sessionRuntimeStatus, exact bool) SessionAttention { if exact { switch exactStatus { + case sessionRuntimeComplete: + return SessionAttentionComplete case sessionRuntimeApproval: return SessionAttentionApproval case sessionRuntimeInput: @@ -1253,10 +1267,12 @@ func mergeSessionAttention(current, next SessionAttention) SessionAttention { func sessionAttentionPriority(attention SessionAttention) int { switch attention { case SessionAttentionApproval: - return 3 + return 4 case SessionAttentionInput: - return 2 + return 3 case SessionAttentionCheck: + return 2 + case SessionAttentionComplete: return 1 default: return 0 diff --git a/internal/codex/live_usage_test.go b/internal/codex/live_usage_test.go index 803a387..d3c1e81 100644 --- a/internal/codex/live_usage_test.go +++ b/internal/codex/live_usage_test.go @@ -431,7 +431,7 @@ func TestLiveUsageReaderTracksDefiniteAwaitingUserLifecycle(t *testing.T) { } } -func TestLiveUsageReaderUsesOpenWriterAndCompletedTurnForInputWait(t *testing.T) { +func TestLiveUsageReaderUsesOpenWriterAndCompletedTurnForCompletion(t *testing.T) { home := t.TempDir() now := time.Now() threadID := "open-idle" @@ -450,8 +450,8 @@ func TestLiveUsageReaderUsesOpenWriterAndCompletedTurnForInputWait(t *testing.T) t.Fatal(err) } usage, err := reader.FetchTokenUsage(context.Background()) - if err != nil || len(usage.Sessions) != 1 || usage.Sessions[0].Attention != SessionAttentionInput { - t.Fatalf("open completed CLI = %#v, %v; want input needed", usage, err) + if err != nil || len(usage.Sessions) != 1 || usage.Sessions[0].Attention != SessionAttentionComplete { + t.Fatalf("open completed CLI = %#v, %v; want turn complete", usage, err) } if !usage.CodexStatusKnown || !usage.CodexUp || usage.CodexWorking { t.Fatalf("waiting writer lock did not report healthy idle Codex: %#v", usage) @@ -468,8 +468,8 @@ func TestLiveUsageReaderUsesOpenWriterAndCompletedTurnForInputWait(t *testing.T) appendRollout(t, path, attentionEventLine(now.Add(2*time.Second), "task_complete", nil)+"\n") usage, err = reader.FetchTokenUsage(context.Background()) - if err != nil || usage.Sessions[0].Attention != SessionAttentionInput { - t.Fatalf("second completed turn = %#v, %v; want input needed", usage, err) + if err != nil || usage.Sessions[0].Attention != SessionAttentionComplete { + t.Fatalf("second completed turn = %#v, %v; want turn complete", usage, err) } if usage.CodexWorking { t.Fatalf("waiting writer lifecycle continued to report Codex activity: %#v", usage) diff --git a/internal/codex/session_approval.go b/internal/codex/session_approval.go index a2631f5..fbfb228 100644 --- a/internal/codex/session_approval.go +++ b/internal/codex/session_approval.go @@ -7,6 +7,7 @@ import ( // SessionApprovalClient is deliberately separate from telemetry. Only live, // fully displayed, ordinary command requests expose a one-use capability. +// Responses must select a supported choice advertised by that exact request. type SessionApprovalClient interface { SessionApprovalPending(string) bool RespondSessionApproval(context.Context, string, string) error diff --git a/internal/codex/session_approval_test.go b/internal/codex/session_approval_test.go index 4bae6ef..5d52aef 100644 --- a/internal/codex/session_approval_test.go +++ b/internal/codex/session_approval_test.go @@ -11,7 +11,7 @@ func approvalFixture(t *testing.T, overrides map[string]any) (map[string]*daemon t.Helper() states := map[string]*daemonContextState{} daemonContextEvent(states, "item/started", nil, json.RawMessage(`{"threadId":"root","turnId":"turn","item":{"id":"cmd","type":"commandExecution","command":"git push","cwd":"/work"}}`), time.Now()) - p := map[string]any{"threadId": "root", "turnId": "turn", "itemId": "cmd", "reason": "Publish branch?"} + p := map[string]any{"threadId": "root", "turnId": "turn", "itemId": "cmd", "reason": "Publish branch?", "availableDecisions": []string{"accept", "decline"}} for k, v := range overrides { p[k] = v } @@ -37,7 +37,6 @@ func TestApprovalCompleteCommandAndFailClosed(t *testing.T) { "bidi": {"command": "echo \u202Ehidden"}, "network": {"networkApprovalContext": map[string]string{"host": "example.com", "protocol": "https"}}, "permissions": {"additionalPermissions": map[string]any{"network": true}}, - "session only": {"availableDecisions": []string{"acceptForSession", "decline"}}, "no decisions": {"availableDecisions": []string{}}, "ambiguous argv": {"command": []string{"echo", "a b"}}, } { @@ -66,3 +65,29 @@ func TestApprovalLifecycleAndReplay(t *testing.T) { t.Fatal("replay reused old capability") } } + +func TestApprovalRejectionDiagnostics(t *testing.T) { + for _, tc := range []struct { + fields map[string]any + reason string + }{ + {map[string]any{"networkApprovalContext": map[string]string{"host": "example.com"}}, "network"}, + {map[string]any{"additionalPermissions": map[string]any{}}, "permissions"}, + {map[string]any{"command": []string{"pwd"}}, "command-format"}, + {map[string]any{"itemId": "unknown"}, "missing-command"}, + {map[string]any{"itemId": "unknown", "command": "pwd"}, "missing-directory"}, + {map[string]any{"turnId": "", "command": "pwd", "cwd": "/work"}, "missing-identity"}, + {map[string]any{"availableDecisions": []string{}}, "decisions"}, + {map[string]any{"command": strings.Repeat("x", 5000)}, "truncated"}, + {map[string]any{"command": "pwd\t"}, "sanitised"}, + } { + _, c := approvalFixture(t, tc.fields) + if c.ApprovalBlocked != tc.reason || c.ApprovalToken != "" { + t.Errorf("want %s, got reason=%s actionable=%v", tc.reason, c.ApprovalBlocked, c.ApprovalToken != "") + } + } + _, c := approvalFixture(t, nil) + if c.ApprovalBlocked != "" || c.ApprovalToken == "" { + t.Fatal("eligible request gained a rejection") + } +} diff --git a/internal/codex/session_approval_unix.go b/internal/codex/session_approval_unix.go index cdcf83c..ae6b5ab 100644 --- a/internal/codex/session_approval_unix.go +++ b/internal/codex/session_approval_unix.go @@ -32,9 +32,6 @@ func (p *daemonStatusProvider) SessionApprovalPending(token string) bool { } func (p *daemonStatusProvider) RespondSessionApproval(ctx context.Context, token, decision string) error { - if decision != "accept" && decision != "decline" { - return errors.New("unsupported approval decision") - } p.mu.Lock() if err := ctx.Err(); err != nil { p.mu.Unlock() @@ -45,11 +42,22 @@ func (p *daemonStatusProvider) RespondSessionApproval(ctx context.Context, token p.mu.Unlock() return errors.New("request expired or connection closed; check Codex") } + var wire string + for _, option := range s.requests[id].ApprovalOptions { + if option.Kind != "" && option.Value == decision { + wire = option.Wire + break + } + } + if wire == "" { + p.mu.Unlock() + return errors.New("decision was not offered by this request") + } // Consume before writing. A failed/ambiguous write must never auto-retry. delete(s.requests, id) connection := p.connection p.mu.Unlock() // Never hold the state lock during IO: rendering can check pending state // without waiting for the network. The server arbitrates concurrent replies. - return p.write(connection, map[string]any{"id": json.RawMessage(id), "result": map[string]string{"decision": decision}}) + return p.write(connection, map[string]any{"id": json.RawMessage(id), "result": map[string]any{"decision": json.RawMessage(wire)}}) } diff --git a/internal/codex/session_approval_unix_test.go b/internal/codex/session_approval_unix_test.go index c4d1f9f..85dac9a 100644 --- a/internal/codex/session_approval_unix_test.go +++ b/internal/codex/session_approval_unix_test.go @@ -83,6 +83,28 @@ func TestApprovalWireOneUseAndDisconnect(t *testing.T) { t.Fatal("missing decline") } p.contexts, c = approvalFixture(t, nil) + for _, decision := range []any{"cancel", "acceptForSession", map[string]any{"acceptWithExecpolicyAmendment": map[string]any{"execpolicy_amendment": []string{"pwd"}}}} { + p.contexts, c = approvalFixture(t, map[string]any{"availableDecisions": []any{decision}}) + if c.ApprovalToken == "" { + t.Fatal("advertised choice unavailable") + } + if err := p.RespondSessionApproval(context.Background(), c.ApprovalToken, "decline"); err == nil { + t.Fatal("unadvertised choice accepted") + } + if err := p.RespondSessionApproval(context.Background(), c.ApprovalToken, c.ApprovalOptions[0].Value); err != nil { + t.Fatal(err) + } + select { + case msg := <-received: + want, _ := json.Marshal(map[string]any{"decision": decision}) + if string(msg["result"]) != string(want) { + t.Fatalf("wire mismatch %s want %s", msg["result"], want) + } + case <-time.After(time.Second): + t.Fatal("missing advertised decision") + } + } + p.contexts, c = approvalFixture(t, nil) conn.Close() if err := p.RespondSessionApproval(context.Background(), c.ApprovalToken, "accept"); err == nil { t.Fatal("closed transport succeeded") @@ -99,3 +121,36 @@ func TestApprovalWireOneUseAndDisconnect(t *testing.T) { t.Fatal("disconnected response allowed") } } + +func TestDaemonSuccessfulCompletionIsSeparateFromIdle(t *testing.T) { + for _, status := range []string{"completed", "interrupted", "failed", ""} { + t.Run(status, func(t *testing.T) { + p := &daemonStatusProvider{contexts: map[string]*daemonContextState{}, statuses: map[string]sessionRuntimeStatus{"root": sessionRuntimeIdle}} + emit := func(method, body string) { + daemonContextEvent(p.contexts, method, nil, json.RawMessage(body), time.Now()) + } + if got := p.statusSnapshotLocked([]string{"root"}).Statuses["root"]; got != sessionRuntimeIdle { + t.Fatal("unobserved idle became completion") + } + emit("turn/completed", `{"threadId":"root","turn":{"id":"turn","status":"`+status+`"}}`) + want := sessionRuntimeIdle + if status == "completed" { + want = sessionRuntimeComplete + } + if got := p.statusSnapshotLocked([]string{"root"}).Statuses["root"]; got != want { + t.Fatalf("completion %q: got %v want %v", status, got, want) + } + for _, active := range []sessionRuntimeStatus{sessionRuntimeWorking, sessionRuntimeInput, sessionRuntimeApproval} { + p.statuses["root"] = active + if got := p.statusSnapshotLocked([]string{"root"}).Statuses["root"]; got != active { + t.Fatal("completion displaced active state") + } + } + p.statuses["root"] = sessionRuntimeIdle + emit("turn/started", `{"threadId":"root","turn":{"id":"next","status":"inProgress"}}`) + if got := p.statusSnapshotLocked([]string{"root"}).Statuses["root"]; got != sessionRuntimeIdle { + t.Fatal("completion survived new turn") + } + }) + } +} diff --git a/internal/codex/session_context.go b/internal/codex/session_context.go index 4caffe9..2f7921a 100644 --- a/internal/codex/session_context.go +++ b/internal/codex/session_context.go @@ -25,15 +25,21 @@ const ( // SessionContext is a bounded, memory-only excerpt, never a generated summary. // It is deliberately separate from token accounting and persisted preferences. type SessionContext struct { + InputToken string + InputQuestions string // ApprovalToken is an opaque, connection-local capability, never persisted. ApprovalToken string - TurnID string - ItemID string - Kind SessionContextKind - Text string - At time.Time - ThreadID string - Source string + // ApprovalBlocked is a canonical diagnostic, with no request content. + ApprovalBlocked string + ApprovalOptions [8]ApprovalOption + ApprovalDecisions string + TurnID string + ItemID string + Kind SessionContextKind + Text string + At time.Time + ThreadID string + Source string } const sessionContextLimit = 4096 @@ -85,6 +91,9 @@ func preferSessionContext(a, b SessionContext) SessionContext { } type contextQuestion struct { + ID string `json:"id"` + IsOther bool `json:"isOther"` + IsSecret bool `json:"isSecret"` Question string `json:"question"` Options []struct { Label string `json:"label"` diff --git a/internal/codex/session_context_daemon.go b/internal/codex/session_context_daemon.go index 5a05b87..94ef191 100644 --- a/internal/codex/session_context_daemon.go +++ b/internal/codex/session_context_daemon.go @@ -10,15 +10,21 @@ import ( // Pending requests are keyed by JSON-RPC request id, so resolving one request // cannot clear a different outstanding question/approval for the same thread. type daemonContextState struct { - latest SessionContext - requests map[string]SessionContext - commands map[string]contextCommandItem + promptToken string + promptSpent bool + completed bool + latest SessionContext + requests map[string]SessionContext + commands map[string]contextCommandItem } type contextCommandItem struct{ Command, CWD string } func daemonContextEvent(states map[string]*daemonContextState, method string, id, raw json.RawMessage, now time.Time) { var p struct { + Turn struct { + Status string `json:"status"` + } `json:"turn"` TurnID string `json:"turnId"` ItemID string `json:"itemId"` CWD string `json:"cwd"` @@ -59,6 +65,8 @@ func daemonContextEvent(states map[string]*daemonContextState, method string, id delete(states, p.ThreadID) return case "turn/completed", "turn/interrupted": + state.promptToken, state.promptSpent = "", false + state.completed = method == "turn/completed" && p.Turn.Status == "completed" clear(state.requests) clear(state.commands) return @@ -84,21 +92,39 @@ func daemonContextEvent(states map[string]*daemonContextState, method string, id if special(p.Permissions) { c.Text += "\nPermissions: " + string(p.Permissions) } - allowed := p.Decisions == nil - if !allowed { - var accept, decline bool - for _, raw := range p.Decisions { - accept = accept || string(raw) == `"accept"` - decline = decline || string(raw) == `"decline"` + c.ApprovalOptions, c.ApprovalDecisions = commandApprovalOptions(p.Decisions) + for _, option := range c.ApprovalOptions { + if option.Detail != "" { + c.Text += "\nPersistent command-prefix rule: " + option.Detail } - allowed = accept && decline } + allowed := c.ApprovalOptions[0].Kind != "" // Fail closed if the display loses ANY command/request content, or if // this is a broader grant rather than an ordinary command decision. - if commandValid && allowed && command != "" && p.CWD != "" && p.TurnID != "" && p.ItemID != "" && !special(p.Network) && !special(p.Permissions) && SanitizeSessionContext(c.Text) == c.Text && SanitizeSessionContext(command) == command && SanitizeSessionContext(p.CWD) == p.CWD { + switch { + case special(p.Network): + c.ApprovalBlocked = "network" + case special(p.Permissions): + c.ApprovalBlocked = "permissions" + case !commandValid: + c.ApprovalBlocked = "command-format" + case command == "": + c.ApprovalBlocked = "missing-command" + case p.CWD == "": + c.ApprovalBlocked = "missing-directory" + case p.TurnID == "" || p.ItemID == "": + c.ApprovalBlocked = "missing-identity" + case !allowed: + c.ApprovalBlocked = "decisions" + case len([]rune(c.Text)) > sessionContextLimit: + c.ApprovalBlocked = "truncated" + case SanitizeSessionContext(c.Text) != c.Text || SanitizeSessionContext(command) != command || SanitizeSessionContext(p.CWD) != p.CWD: + c.ApprovalBlocked = "sanitised" + default: c.ApprovalToken = rand.Text() } case "item/fileChange/requestApproval": + c.ApprovalBlocked = "file-change" c.Kind, c.Text = SessionContextApproval, p.Reason if c.Text == "" { c.Text = "File changes requested" @@ -107,6 +133,7 @@ func daemonContextEvent(states map[string]*daemonContextState, method string, id c.Text += "\nRoot: " + p.GrantRoot } case "item/permissions/requestApproval": + c.ApprovalBlocked = "permissions" c.Kind, c.Text = SessionContextApproval, p.Reason if c.Text == "" { c.Text = "Additional permissions requested" @@ -122,6 +149,12 @@ func daemonContextEvent(states map[string]*daemonContextState, method string, id return } c.Kind, c.Text = SessionContextQuestion, questionContext(p.Questions) + if p.TurnID != "" && p.ItemID != "" && validPromptQuestions(p.Questions) && SanitizeSessionContext(c.Text) == c.Text { + encoded, _ := json.Marshal(p.Questions) + if len(encoded) <= 8192 { + c.InputToken, c.InputQuestions = rand.Text(), string(encoded) + } + } case "item/completed": for key, request := range state.requests { if p.Item.ID != "" && request.ItemID == p.Item.ID && request.TurnID == p.TurnID { @@ -164,6 +197,7 @@ func daemonContextEvent(states map[string]*daemonContextState, method string, id return } if c.pending() { + state.promptToken = "" if len(id) == 0 || string(id) == "null" { return } diff --git a/internal/codex/session_context_test.go b/internal/codex/session_context_test.go index 1ad4c1b..2b46966 100644 --- a/internal/codex/session_context_test.go +++ b/internal/codex/session_context_test.go @@ -19,6 +19,39 @@ func contextRecord(t *testing.T, kind string, payload any) []byte { return b } +func TestCompletedRootAttentionAndWorkingChild(t *testing.T) { + now := time.Now() + r := &LiveUsageReader{files: map[string]*rolloutCursor{ + "root": {threadID: "root", lastModified: now}, + "child": {threadID: "child", parentThreadID: "root", nonRoot: true, lastModified: now}, + }} + statuses := map[string]sessionRuntimeStatus{"root": sessionRuntimeComplete, "child": sessionRuntimeIdle} + sessions, _, _ := r.sessionSnapshots(now, nil, false, statuses) + if len(sessions) != 1 || sessions[0].Attention != SessionAttentionComplete { + t.Fatalf("completion missing: %+v", sessions) + } + for _, child := range []sessionRuntimeStatus{sessionRuntimeWorking, sessionRuntimeInput, sessionRuntimeApproval} { + statuses["child"] = child + sessions, _, _ = r.sessionSnapshots(now, nil, false, statuses) + want := SessionAttentionNone + if child == sessionRuntimeInput { + want = SessionAttentionInput + } + if child == sessionRuntimeApproval { + want = SessionAttentionApproval + } + if sessions[0].Attention != want { + t.Fatalf("child %v got %v want %v", child, sessions[0].Attention, want) + } + } + statuses["root"] = sessionRuntimeIdle + statuses["child"] = sessionRuntimeComplete + sessions, _, _ = r.sessionSnapshots(now, nil, false, statuses) + if sessions[0].Attention != SessionAttentionNone { + t.Fatal("child completion labelled whole root complete") + } +} + func TestSessionContextExtractsOnlyDisplayText(t *testing.T) { cursor := &rolloutCursor{threadID: "root"} for _, tc := range []struct { diff --git a/internal/codex/session_prompt.go b/internal/codex/session_prompt.go new file mode 100644 index 0000000..863d3ca --- /dev/null +++ b/internal/codex/session_prompt.go @@ -0,0 +1,100 @@ +package codex + +import ( + "context" + "encoding/json" + "errors" + "strings" +) + +// SessionPromptOffer is connection-local. Neither drafts nor capabilities are persisted. +type SessionPromptOffer struct { + Token string + ThreadID string + Questions []PromptQuestion +} + +type PromptQuestion struct { + ID string + Text string + Secret bool + FreeText bool + Options []string +} + +type SessionPromptClient interface { + SessionPrompt(string) SessionPromptOffer + SendSessionPrompt(context.Context, string, []string) error +} + +func (c Client) SessionPrompt(thread string) SessionPromptOffer { + if c.LiveUsage != nil { + if p, ok := c.LiveUsage.statusProvider.(SessionPromptClient); ok { + return p.SessionPrompt(thread) + } + } + return SessionPromptOffer{} +} + +func (c Client) SendSessionPrompt(ctx context.Context, token string, answers []string) error { + if c.LiveUsage != nil { + if p, ok := c.LiveUsage.statusProvider.(SessionPromptClient); ok { + return p.SendSessionPrompt(ctx, token, answers) + } + } + return errors.New("prompt unavailable; reply in Codex") +} + +func validPromptQuestions(qs []contextQuestion) bool { + if len(qs) < 1 || len(qs) > 3 { + return false + } + seen := map[string]bool{} + for _, q := range qs { + if q.ID == "" || seen[q.ID] || q.Question == "" || len(q.Options) > 16 { + return false + } + seen[q.ID] = true + } + return true +} + +func inputOffer(c SessionContext) SessionPromptOffer { + var qs []contextQuestion + if c.InputToken == "" || json.Unmarshal([]byte(c.InputQuestions), &qs) != nil { + return SessionPromptOffer{} + } + o := SessionPromptOffer{Token: c.InputToken, ThreadID: c.ThreadID} + for _, q := range qs { + p := PromptQuestion{ID: q.ID, Text: q.Question, Secret: q.IsSecret, FreeText: q.IsOther || len(q.Options) == 0} + for _, option := range q.Options { + p.Options = append(p.Options, option.Label) + } + o.Questions = append(o.Questions, p) + } + return o +} + +func validPromptAnswers(o SessionPromptOffer, answers []string) bool { + want := max(len(o.Questions), 1) + if len(answers) != want { + return false + } + for i, a := range answers { + if strings.TrimSpace(a) == "" || len([]rune(a)) > sessionContextLimit || SanitizeSessionContext(a) != strings.TrimSpace(a) { + return false + } + if len(o.Questions) > 0 && !o.Questions[i].FreeText { + found := false + for _, option := range o.Questions[i].Options { + if a == option { + found = true + } + } + if !found { + return false + } + } + } + return true +} diff --git a/internal/codex/session_prompt_unix.go b/internal/codex/session_prompt_unix.go new file mode 100644 index 0000000..3d4b796 --- /dev/null +++ b/internal/codex/session_prompt_unix.go @@ -0,0 +1,168 @@ +//go:build unix + +package codex + +import ( + "context" + "crypto/rand" + "encoding/json" + "errors" +) + +// Runtime events invalidate editors immediately, between telemetry polls. +func (p *daemonStatusProvider) promptLifecycleLocked(method string, raw json.RawMessage) { + var event struct { + ThreadID string `json:"threadId"` + Status struct { + Type string `json:"type"` + ActiveFlags []string `json:"activeFlags"` + } `json:"status"` + } + if json.Unmarshal(raw, &event) != nil || event.ThreadID == "" { + return + } + status := sessionRuntimeUnknown + switch method { + case "turn/started": + status = sessionRuntimeWorking + case "turn/completed", "turn/interrupted": + status = sessionRuntimeIdle + case "thread/status/changed": + status = parseSessionRuntimeStatus(event.Status.Type, event.Status.ActiveFlags) + case "thread/closed": + delete(p.statuses, event.ThreadID) + delete(p.subscribed, event.ThreadID) + return + default: + return + } + if p.statuses == nil { + p.statuses = map[string]sessionRuntimeStatus{} + } + p.statuses[event.ThreadID] = status + if status != sessionRuntimeIdle { + if s := p.contexts[event.ThreadID]; s != nil { + s.promptToken = "" + } + } +} + +func (p *daemonStatusProvider) SessionPrompt(thread string) SessionPromptOffer { + p.mu.Lock() + defer p.mu.Unlock() + if p.connection == nil { + return SessionPromptOffer{} + } + if _, ok := p.subscribed[thread]; !ok { + return SessionPromptOffer{} + } + s := p.contexts[thread] + if s != nil && len(s.requests) > 0 { + // Do not combine a question with an outstanding approval, or guess + // which of multiple simultaneous input requests the user is answering. + if len(s.requests) != 1 { + return SessionPromptOffer{} + } + for _, c := range s.requests { + return inputOffer(c) + } + } + if p.statuses[thread] != sessionRuntimeIdle { + return SessionPromptOffer{} + } + if s == nil { + if len(p.contexts) >= 256 { + return SessionPromptOffer{} + } + if p.contexts == nil { + p.contexts = map[string]*daemonContextState{} + } + s = &daemonContextState{requests: map[string]SessionContext{}, commands: map[string]contextCommandItem{}} + p.contexts[thread] = s + } + if s.promptToken == "" { + if s.promptSpent { + return SessionPromptOffer{} + } + s.promptToken = rand.Text() + } + return SessionPromptOffer{Token: s.promptToken, ThreadID: thread} +} + +func (p *daemonStatusProvider) SendSessionPrompt(ctx context.Context, token string, answers []string) error { + if err := ctx.Err(); err != nil { + return err + } + p.mu.Lock() + connection := p.connection + var offer SessionPromptOffer + var requestID string + var state *daemonContextState + if token != "" && connection != nil { + for thread, s := range p.contexts { + if _, ok := p.subscribed[thread]; !ok { + continue + } + if s.promptToken == token && len(s.requests) == 0 && p.statuses[thread] == sessionRuntimeIdle { + offer = SessionPromptOffer{Token: token, ThreadID: thread} + state = s + break + } + if len(s.requests) == 1 { + for id, c := range s.requests { + if c.InputToken == token { + offer = inputOffer(c) + requestID = id + state = s + } + } + } + } + } + if state == nil || !validPromptAnswers(offer, answers) { + p.mu.Unlock() + return errors.New("prompt expired or answer invalid; check Codex") + } + // Consume the capability before any IO. Never automatically retry an + // ambiguous write, or send it over a replacement connection. + if requestID != "" { + delete(state.requests, requestID) + } else { + state.promptToken = "" + state.promptSpent = true + p.statuses[offer.ThreadID] = sessionRuntimeWorking + } + p.mu.Unlock() + if requestID != "" { + mapped := map[string]any{} + for i, q := range offer.Questions { + mapped[q.ID] = map[string]any{"answers": []string{answers[i]}} + } + return p.write(connection, map[string]any{"id": json.RawMessage(requestID), "result": map[string]any{"answers": mapped}}) + } + // Recheck live runtime immediately before dispatch: idle telemetry may + // have aged while the user composed their prompt. + var read struct { + Thread struct { + Status struct { + Type string `json:"type"` + } `json:"status"` + } `json:"thread"` + } + if err := p.requestOn(ctx, connection, "thread/read", map[string]any{"threadId": offer.ThreadID, "includeTurns": false}, &read); err != nil { + return err + } + if read.Thread.Status.Type != "idle" { + return errors.New("session is no longer idle; reply in Codex") + } + p.mu.Lock() + unchanged := p.connection == connection && p.contexts[offer.ThreadID] == state && len(state.requests) == 0 + p.mu.Unlock() + if !unchanged { + return errors.New("session changed; reply in Codex") + } + return p.requestOn(ctx, connection, "turn/start", map[string]any{ + "threadId": offer.ThreadID, + "input": []any{map[string]any{"type": "text", "text": answers[0]}}, + }, nil) +} diff --git a/internal/codex/session_prompt_unix_test.go b/internal/codex/session_prompt_unix_test.go new file mode 100644 index 0000000..b967d37 --- /dev/null +++ b/internal/codex/session_prompt_unix_test.go @@ -0,0 +1,167 @@ +//go:build unix + +package codex + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/gorilla/websocket" +) + +func promptProvider(t *testing.T, runtime string) (*daemonStatusProvider, <-chan daemonEnvelope) { + t.Helper() + received := make(chan daemonEnvelope, 16) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + upgrader := websocket.Upgrader{} + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + return + } + defer conn.Close() + for { + var e daemonEnvelope + if conn.ReadJSON(&e) != nil { + return + } + received <- e + if e.Method != "" { + result := map[string]any{} + if e.Method == "thread/read" { + result = map[string]any{"thread": map[string]any{"status": map[string]any{"type": runtime}}} + } + if conn.WriteJSON(map[string]any{"id": e.ID, "result": result}) != nil { + return + } + } + } + })) + conn, _, err := websocket.DefaultDialer.Dial("ws"+strings.TrimPrefix(server.URL, "http"), nil) + if err != nil { + t.Fatal(err) + } + p := &daemonStatusProvider{connection: conn, contexts: map[string]*daemonContextState{}, pending: map[int64]chan daemonEnvelope{}, subscribed: map[string]struct{}{"root": {}}, statuses: map[string]sessionRuntimeStatus{"root": sessionRuntimeIdle}} + go p.readLoop(conn) + t.Cleanup(func() { p.disconnect(conn); server.Close() }) + return p, received +} + +func TestSessionPromptIdleWireAndOneUse(t *testing.T) { + p, received := promptProvider(t, "idle") + o := p.SessionPrompt("root") + if o.Token == "" || p.SessionPrompt("root").Token != o.Token || p.SessionPrompt("other").Token != "" { + t.Fatal("invalid capability") + } + if err := p.SendSessionPrompt(context.Background(), o.Token, []string{"Hello 世界"}); err != nil { + t.Fatal(err) + } + read, start := <-received, <-received + if read.Method != "thread/read" || start.Method != "turn/start" { + t.Fatal(read.Method, start.Method) + } + var params map[string]json.RawMessage + json.Unmarshal(start.Params, ¶ms) + if len(params) != 2 || string(params["threadId"]) != `"root"` || string(params["input"]) != `[{"text":"Hello 世界","type":"text"}]` { + t.Fatal(string(start.Params)) + } + if err := p.SendSessionPrompt(context.Background(), o.Token, []string{"again"}); err == nil { + t.Fatal("duplicate allowed") + } + if p.SessionPrompt("root").Token != "" { + t.Fatal("new capability minted while sending") + } +} + +func TestSessionPromptFreshBusyCheck(t *testing.T) { + p, received := promptProvider(t, "active") + o := p.SessionPrompt("root") + if err := p.SendSessionPrompt(context.Background(), o.Token, []string{"do not send"}); err == nil { + t.Fatal("fresh busy status ignored") + } + if e := <-received; e.Method != "thread/read" { + t.Fatal(e.Method) + } + select { + case e := <-received: + t.Fatal("unexpected send", e.Method) + default: + } +} + +func TestSessionPromptQuestionsWireAndInvalidation(t *testing.T) { + p, received := promptProvider(t, "active") + question := json.RawMessage(`{"threadId":"root","turnId":"turn","itemId":"item","questions":[{"id":"pick","question":"Choose","options":[{"label":"First","description":"One"}]},{"id":"why","question":"Why?","isOther":true,"isSecret":true}]}`) + emit := func(method string, id, body json.RawMessage) { + p.mu.Lock() + daemonContextEvent(p.contexts, method, id, body, time.Now()) + p.promptLifecycleLocked(method, body) + p.mu.Unlock() + } + emit("item/tool/requestUserInput", json.RawMessage(`"request"`), question) + o := p.SessionPrompt("root") + if len(o.Questions) != 2 || !o.Questions[1].Secret || o.Questions[0].FreeText { + t.Fatal(o) + } + for _, a := range [][]string{{"First"}, {"Other", "why"}, {"First", ""}, {"First", "bad\x1b[31m"}} { + if err := p.SendSessionPrompt(context.Background(), o.Token, a); err == nil { + t.Fatal("invalid answers sent", a) + } + } + if err := p.SendSessionPrompt(context.Background(), o.Token, []string{"First", "Because"}); err != nil { + t.Fatal(err) + } + e := <-received + if string(e.ID) != `"request"` || string(e.Result) != `{"answers":{"pick":{"answers":["First"]},"why":{"answers":["Because"]}}}` { + t.Fatal(string(e.Result)) + } + for _, method := range []string{"serverRequest/resolved", "turn/started", "thread/closed"} { + p.mu.Lock() + p.subscribed["root"] = struct{}{} + p.mu.Unlock() + emit("item/tool/requestUserInput", json.RawMessage(`"request"`), question) + o = p.SessionPrompt("root") + emit(method, nil, json.RawMessage(`{"threadId":"root","requestId":"request"}`)) + if err := p.SendSessionPrompt(context.Background(), o.Token, []string{"First", "Because"}); err == nil { + t.Fatal("stale request sent", method) + } + } +} + +func TestSessionPromptBlocksUnsafeOrMixedRequests(t *testing.T) { + p, _ := promptProvider(t, "idle") + for _, raw := range []string{ + `{"questions":[{"question":"Missing ID"}]}`, + `{"questions":[{"id":"same","question":"a"},{"id":"same","question":"b"}]}`, + `{"questions":[{"id":"x","question":"hidden\u001b[31m"}]}`, + } { + var fields map[string]any + json.Unmarshal([]byte(raw), &fields) + fields["threadId"], fields["turnId"], fields["itemId"] = "root", "turn", "item" + body, _ := json.Marshal(fields) + p.mu.Lock() + daemonContextEvent(p.contexts, "item/tool/requestUserInput", json.RawMessage(`1`), body, time.Now()) + p.mu.Unlock() + if p.SessionPrompt("root").Token != "" { + t.Fatal("unsafe prompt offered", raw) + } + } + p.mu.Lock() + p.contexts = map[string]*daemonContextState{} + p.mu.Unlock() + o := p.SessionPrompt("root") + p.mu.Lock() + p.promptLifecycleLocked("turn/started", json.RawMessage(`{"threadId":"root"}`)) + p.mu.Unlock() + if err := p.SendSessionPrompt(context.Background(), o.Token, []string{"stale"}); err == nil { + t.Fatal("active session accepted old editor") + } + p.disconnect(p.connection) + if p.SessionPrompt("root").Token != "" { + t.Fatal("disconnected editor offered") + } +} diff --git a/internal/i18n/locales/de.json b/internal/i18n/locales/de.json index 5c49aeb..1de6cea 100644 --- a/internal/i18n/locales/de.json +++ b/internal/i18n/locales/de.json @@ -1,9 +1,41 @@ { + "FOLLOW-UP": "NACHFRAGE", + "REPLY": "ANTWORT", + "[ Click here or press Enter to write ]": "[ Hier klicken oder Enter zum Schreiben ]", + "Enter: send / next answer • Esc: leave editor • ↑/↓: choices": "Enter: senden / weiter • Esc: Editor verlassen • ↑/↓: Auswahl", + "Sending…": "Wird gesendet…", + "Text sent; check Codex for the outcome.": "Text gesendet; Ergebnis in Codex prüfen.", + "Send unconfirmed; check Codex before retrying.": "Senden unbestätigt; vor erneutem Versuch Codex prüfen.", + "Prompt changed; review the session before replying.": "Eingabe geändert; Sitzung vor dem Antworten prüfen.", + "Choose an offered answer with ↑/↓.": "Mit ↑/↓ eine angebotene Antwort wählen.", + "OPEN DETAIL": "DETAILS ÖFFNEN", + "No supported approval choices were offered.": "Keine unterstützten Genehmigungsoptionen angeboten.", + "[ ALLOW FOR SESSION ]": "[ FÜR SITZUNG ERLAUBEN ]", + "[ CONFIRM SESSION GRANT ]": "[ SITZUNGSFREIGABE BESTÄTIGEN ]", + "[ ALWAYS ALLOW PREFIX ]": "[ PRÄFIX IMMER ERLAUBEN ]", + "[ CONFIRM PERSISTENT RULE ]": "[ DAUERHAFTE REGEL BESTÄTIGEN ]", + "[ REJECT & STOP TURN ]": "[ ABLEHNEN UND TURN STOPPEN ]", + "OFFERED DECISIONS": "ANGEBOTENE OPTIONEN", + "Questions must be answered in Codex.": "Fragen müssen in Codex beantwortet werden.", + "Local observation cannot answer live approvals.": "Lokale Beobachtung kann keine Live-Genehmigung erteilen.", + "Network approval is not supported here.": "Netzwerkgenehmigungen werden hier nicht unterstützt.", + "Additional permissions require approval in Codex.": "Zusätzliche Berechtigungen müssen in Codex genehmigt werden.", + "File-change approval is not supported here.": "Dateiänderungen können hier nicht genehmigt werden.", + "Unsupported command format.": "Nicht unterstütztes Befehlsformat.", + "Command details are missing.": "Befehlsdetails fehlen.", + "Working directory is missing.": "Arbeitsverzeichnis fehlt.", + "Turn or item identity is missing.": "Turn- oder Elementkennung fehlt.", + "One-time approval and decline are not both offered.": "Einmalige Genehmigung und Ablehnung werden nicht beide angeboten.", + "Request details exceed the display limit.": "Anfragedetails überschreiten das Anzeigelimit.", + "Request text was altered for safe display.": "Anfragetext wurde für sichere Anzeige verändert.", + "No actionable live approval was received.": "Keine bearbeitbare Live-Genehmigung empfangen.", + "Request resolved, expired, or disconnected.": "Anfrage erledigt, abgelaufen oder Verbindung getrennt.", + "Enlarge the terminal to show approval controls.": "Terminal vergrößern, um Genehmigungsschaltflächen anzuzeigen.", "[ APPROVE ONCE ]": "[ EINMAL ERLAUBEN ]", "[ CONFIRM APPROVAL ]": "[ GENEHMIGUNG BESTÄTIGEN ]", "[ DECLINE ]": "[ ABLEHNEN ]", "Sending decision…": "Entscheidung wird gesendet…", - "Decision sent; check Codex for the outcome.": "Entscheidung gesendet; Ergebnis in Codex prüfen.", + "Decision sent…": "Entscheidung gesendet…", "Decision unconfirmed; check Codex. Do not retry here.": "Entscheidung unbestätigt; Codex prüfen. Hier nicht erneut versuchen.", "LAST REPLY": "LETZTE ANTWORT", "QUESTION": "FRAGE", diff --git a/internal/i18n/locales/en-GB.json b/internal/i18n/locales/en-GB.json index 45d1b33..f583837 100644 --- a/internal/i18n/locales/en-GB.json +++ b/internal/i18n/locales/en-GB.json @@ -1,9 +1,41 @@ { + "FOLLOW-UP": "FOLLOW-UP", + "REPLY": "REPLY", + "[ Click here or press Enter to write ]": "[ Click here or press Enter to write ]", + "Enter: send / next answer • Esc: leave editor • ↑/↓: choices": "Enter: send / next answer • Esc: leave editor • ↑/↓: choices", + "Sending…": "Sending…", + "Text sent; check Codex for the outcome.": "Text sent; check Codex for the outcome.", + "Send unconfirmed; check Codex before retrying.": "Send unconfirmed; check Codex before retrying.", + "Prompt changed; review the session before replying.": "Prompt changed; review the session before replying.", + "Choose an offered answer with ↑/↓.": "Choose an offered answer with ↑/↓.", + "OPEN DETAIL": "OPEN DETAIL", + "No supported approval choices were offered.": "No supported approval choices were offered.", + "[ ALLOW FOR SESSION ]": "[ ALLOW FOR SESSION ]", + "[ CONFIRM SESSION GRANT ]": "[ CONFIRM SESSION GRANT ]", + "[ ALWAYS ALLOW PREFIX ]": "[ ALWAYS ALLOW PREFIX ]", + "[ CONFIRM PERSISTENT RULE ]": "[ CONFIRM PERSISTENT RULE ]", + "[ REJECT & STOP TURN ]": "[ REJECT & STOP TURN ]", + "OFFERED DECISIONS": "OFFERED DECISIONS", + "Questions must be answered in Codex.": "Questions must be answered in Codex.", + "Local observation cannot answer live approvals.": "Local observation cannot answer live approvals.", + "Network approval is not supported here.": "Network approval is not supported here.", + "Additional permissions require approval in Codex.": "Additional permissions require approval in Codex.", + "File-change approval is not supported here.": "File-change approval is not supported here.", + "Unsupported command format.": "Unsupported command format.", + "Command details are missing.": "Command details are missing.", + "Working directory is missing.": "Working directory is missing.", + "Turn or item identity is missing.": "Turn or item identity is missing.", + "One-time approval and decline are not both offered.": "One-time approval and decline are not both offered.", + "Request details exceed the display limit.": "Request details exceed the display limit.", + "Request text was altered for safe display.": "Request text was altered for safe display.", + "No actionable live approval was received.": "No actionable live approval was received.", + "Request resolved, expired, or disconnected.": "Request resolved, expired, or disconnected.", + "Enlarge the terminal to show approval controls.": "Enlarge the terminal to show approval controls.", "[ APPROVE ONCE ]": "[ APPROVE ONCE ]", "[ CONFIRM APPROVAL ]": "[ CONFIRM APPROVAL ]", "[ DECLINE ]": "[ DECLINE ]", "Sending decision…": "Sending decision…", - "Decision sent; check Codex for the outcome.": "Decision sent; check Codex for the outcome.", + "Decision sent…": "Decision sent…", "Decision unconfirmed; check Codex. Do not retry here.": "Decision unconfirmed; check Codex. Do not retry here.", "LAST REPLY": "LAST REPLY", "QUESTION": "QUESTION", diff --git a/internal/i18n/locales/es.json b/internal/i18n/locales/es.json index 36d7314..207fc85 100644 --- a/internal/i18n/locales/es.json +++ b/internal/i18n/locales/es.json @@ -1,9 +1,41 @@ { + "FOLLOW-UP": "SEGUIMIENTO", + "REPLY": "RESPUESTA", + "[ Click here or press Enter to write ]": "[ Haz clic aquí o pulsa Intro para escribir ]", + "Enter: send / next answer • Esc: leave editor • ↑/↓: choices": "Intro: enviar / siguiente • Esc: salir • ↑/↓: opciones", + "Sending…": "Enviando…", + "Text sent; check Codex for the outcome.": "Texto enviado; comprueba el resultado en Codex.", + "Send unconfirmed; check Codex before retrying.": "Envío sin confirmar; comprueba Codex antes de reintentar.", + "Prompt changed; review the session before replying.": "Solicitud cambiada; revisa la sesión antes de responder.", + "Choose an offered answer with ↑/↓.": "Elige una respuesta ofrecida con ↑/↓.", + "OPEN DETAIL": "ABRIR DETALLES", + "No supported approval choices were offered.": "No se ofrecieron opciones de aprobación compatibles.", + "[ ALLOW FOR SESSION ]": "[ PERMITIR DURANTE LA SESIÓN ]", + "[ CONFIRM SESSION GRANT ]": "[ CONFIRMAR PERMISO DE SESIÓN ]", + "[ ALWAYS ALLOW PREFIX ]": "[ PERMITIR SIEMPRE EL PREFIJO ]", + "[ CONFIRM PERSISTENT RULE ]": "[ CONFIRMAR REGLA PERMANENTE ]", + "[ REJECT & STOP TURN ]": "[ RECHAZAR Y DETENER TURNO ]", + "OFFERED DECISIONS": "OPCIONES OFRECIDAS", + "Questions must be answered in Codex.": "Las preguntas deben responderse en Codex.", + "Local observation cannot answer live approvals.": "La observación local no permite responder a aprobaciones en vivo.", + "Network approval is not supported here.": "La aprobación de red no se admite aquí.", + "Additional permissions require approval in Codex.": "Los permisos adicionales requieren aprobación en Codex.", + "File-change approval is not supported here.": "La aprobación de cambios de archivos no se admite aquí.", + "Unsupported command format.": "Formato de comando no compatible.", + "Command details are missing.": "Faltan los detalles del comando.", + "Working directory is missing.": "Falta el directorio de trabajo.", + "Turn or item identity is missing.": "Falta la identidad del turno o elemento.", + "One-time approval and decline are not both offered.": "No se ofrecen ambas opciones: aprobación única y rechazo.", + "Request details exceed the display limit.": "Los detalles superan el límite de visualización.", + "Request text was altered for safe display.": "El texto se modificó para mostrarlo de forma segura.", + "No actionable live approval was received.": "No se recibió ninguna aprobación en vivo utilizable.", + "Request resolved, expired, or disconnected.": "Solicitud resuelta, caducada o conexión interrumpida.", + "Enlarge the terminal to show approval controls.": "Amplía el terminal para mostrar los controles de aprobación.", "[ APPROVE ONCE ]": "[ APROBAR UNA VEZ ]", "[ CONFIRM APPROVAL ]": "[ CONFIRMAR APROBACIÓN ]", "[ DECLINE ]": "[ RECHAZAR ]", "Sending decision…": "Enviando decisión…", - "Decision sent; check Codex for the outcome.": "Decisión enviada; consulta el resultado en Codex.", + "Decision sent…": "Decisión enviada…", "Decision unconfirmed; check Codex. Do not retry here.": "Decisión sin confirmar; revisa Codex. No lo reintentes aquí.", "LAST REPLY": "ÚLTIMA RESPUESTA", "QUESTION": "PREGUNTA", diff --git a/internal/i18n/locales/fr.json b/internal/i18n/locales/fr.json index ec0a126..ed49168 100644 --- a/internal/i18n/locales/fr.json +++ b/internal/i18n/locales/fr.json @@ -1,9 +1,41 @@ { + "FOLLOW-UP": "SUIVI", + "REPLY": "RÉPONSE", + "[ Click here or press Enter to write ]": "[ Cliquez ici ou Entrée pour écrire ]", + "Enter: send / next answer • Esc: leave editor • ↑/↓: choices": "Entrée : envoyer / suivant • Échap : quitter • ↑/↓ : choix", + "Sending…": "Envoi…", + "Text sent; check Codex for the outcome.": "Texte envoyé ; consultez le résultat dans Codex.", + "Send unconfirmed; check Codex before retrying.": "Envoi non confirmé ; vérifiez Codex avant de réessayer.", + "Prompt changed; review the session before replying.": "Invite modifiée ; vérifiez la session avant de répondre.", + "Choose an offered answer with ↑/↓.": "Choisissez une réponse proposée avec ↑/↓.", + "OPEN DETAIL": "OUVRIR LES DÉTAILS", + "No supported approval choices were offered.": "Aucun choix d’approbation pris en charge n’a été proposé.", + "[ ALLOW FOR SESSION ]": "[ AUTORISER POUR LA SESSION ]", + "[ CONFIRM SESSION GRANT ]": "[ CONFIRMER POUR LA SESSION ]", + "[ ALWAYS ALLOW PREFIX ]": "[ TOUJOURS AUTORISER LE PRÉFIXE ]", + "[ CONFIRM PERSISTENT RULE ]": "[ CONFIRMER LA RÈGLE PERMANENTE ]", + "[ REJECT & STOP TURN ]": "[ REFUSER ET ARRÊTER LE TOUR ]", + "OFFERED DECISIONS": "CHOIX PROPOSÉS", + "Questions must be answered in Codex.": "Les questions doivent être traitées dans Codex.", + "Local observation cannot answer live approvals.": "L’observation locale ne permet pas d’approuver les demandes en direct.", + "Network approval is not supported here.": "L’approbation réseau n’est pas prise en charge ici.", + "Additional permissions require approval in Codex.": "Les autorisations supplémentaires doivent être approuvées dans Codex.", + "File-change approval is not supported here.": "L’approbation des modifications de fichiers n’est pas prise en charge ici.", + "Unsupported command format.": "Format de commande non pris en charge.", + "Command details are missing.": "Les détails de la commande sont manquants.", + "Working directory is missing.": "Le répertoire de travail est manquant.", + "Turn or item identity is missing.": "L’identifiant du tour ou de l’élément est manquant.", + "One-time approval and decline are not both offered.": "L’approbation unique et le refus ne sont pas tous deux proposés.", + "Request details exceed the display limit.": "Les détails dépassent la limite d’affichage.", + "Request text was altered for safe display.": "Le texte a été modifié pour un affichage sûr.", + "No actionable live approval was received.": "Aucune approbation en direct exploitable reçue.", + "Request resolved, expired, or disconnected.": "Demande résolue, expirée ou connexion interrompue.", + "Enlarge the terminal to show approval controls.": "Agrandissez le terminal pour afficher les boutons d’approbation.", "[ APPROVE ONCE ]": "[ AUTORISER UNE FOIS ]", "[ CONFIRM APPROVAL ]": "[ CONFIRMER L’AUTORISATION ]", "[ DECLINE ]": "[ REFUSER ]", "Sending decision…": "Envoi de la décision…", - "Decision sent; check Codex for the outcome.": "Décision envoyée ; consultez le résultat dans Codex.", + "Decision sent…": "Décision envoyée…", "Decision unconfirmed; check Codex. Do not retry here.": "Décision non confirmée ; vérifiez Codex. Ne réessayez pas ici.", "LAST REPLY": "DERNIÈRE RÉPONSE", "QUESTION": "QUESTION", diff --git a/internal/i18n/locales/it.json b/internal/i18n/locales/it.json index 8f2f991..d033f93 100644 --- a/internal/i18n/locales/it.json +++ b/internal/i18n/locales/it.json @@ -1,9 +1,41 @@ { + "FOLLOW-UP": "SEGUITO", + "REPLY": "RISPOSTA", + "[ Click here or press Enter to write ]": "[ Clicca qui o premi Invio per scrivere ]", + "Enter: send / next answer • Esc: leave editor • ↑/↓: choices": "Invio: invia / avanti • Esc: esci • ↑/↓: opzioni", + "Sending…": "Invio…", + "Text sent; check Codex for the outcome.": "Testo inviato; controlla l’esito in Codex.", + "Send unconfirmed; check Codex before retrying.": "Invio non confermato; controlla Codex prima di riprovare.", + "Prompt changed; review the session before replying.": "Richiesta cambiata; controlla la sessione prima di rispondere.", + "Choose an offered answer with ↑/↓.": "Scegli una risposta proposta con ↑/↓.", + "OPEN DETAIL": "APRI DETTAGLI", + "No supported approval choices were offered.": "Nessuna opzione di approvazione supportata disponibile.", + "[ ALLOW FOR SESSION ]": "[ CONSENTI PER LA SESSIONE ]", + "[ CONFIRM SESSION GRANT ]": "[ CONFERMA PER LA SESSIONE ]", + "[ ALWAYS ALLOW PREFIX ]": "[ CONSENTI SEMPRE IL PREFISSO ]", + "[ CONFIRM PERSISTENT RULE ]": "[ CONFERMA REGOLA PERMANENTE ]", + "[ REJECT & STOP TURN ]": "[ RIFIUTA E FERMA IL TURNO ]", + "OFFERED DECISIONS": "OPZIONI OFFERTE", + "Questions must be answered in Codex.": "Rispondi alle domande in Codex.", + "Local observation cannot answer live approvals.": "L’osservazione locale non può rispondere alle approvazioni live.", + "Network approval is not supported here.": "L’approvazione di rete non è supportata qui.", + "Additional permissions require approval in Codex.": "I permessi aggiuntivi richiedono approvazione in Codex.", + "File-change approval is not supported here.": "L’approvazione delle modifiche ai file non è supportata qui.", + "Unsupported command format.": "Formato del comando non supportato.", + "Command details are missing.": "Mancano i dettagli del comando.", + "Working directory is missing.": "Manca la directory di lavoro.", + "Turn or item identity is missing.": "Manca l’identità del turno o dell’elemento.", + "One-time approval and decline are not both offered.": "Approvazione singola e rifiuto non sono entrambi disponibili.", + "Request details exceed the display limit.": "I dettagli superano il limite di visualizzazione.", + "Request text was altered for safe display.": "Il testo è stato modificato per una visualizzazione sicura.", + "No actionable live approval was received.": "Nessuna approvazione live utilizzabile ricevuta.", + "Request resolved, expired, or disconnected.": "Richiesta risolta, scaduta o connessione interrotta.", + "Enlarge the terminal to show approval controls.": "Ingrandisci il terminale per mostrare i pulsanti di approvazione.", "[ APPROVE ONCE ]": "[ APPROVA UNA VOLTA ]", "[ CONFIRM APPROVAL ]": "[ CONFERMA APPROVAZIONE ]", "[ DECLINE ]": "[ RIFIUTA ]", "Sending decision…": "Invio della decisione…", - "Decision sent; check Codex for the outcome.": "Decisione inviata; verifica l’esito in Codex.", + "Decision sent…": "Decisione inviata…", "Decision unconfirmed; check Codex. Do not retry here.": "Decisione non confermata; controlla Codex. Non riprovare qui.", "LAST REPLY": "ULTIMA RISPOSTA", "QUESTION": "DOMANDA", diff --git a/internal/i18n/locales/ja.json b/internal/i18n/locales/ja.json index dac85b1..ac8a5fa 100644 --- a/internal/i18n/locales/ja.json +++ b/internal/i18n/locales/ja.json @@ -1,9 +1,41 @@ { + "FOLLOW-UP": "続けて質問", + "REPLY": "回答", + "[ Click here or press Enter to write ]": "[ クリックまたは Enter で入力 ]", + "Enter: send / next answer • Esc: leave editor • ↑/↓: choices": "Enter: 送信/次の回答 • Esc: 入力終了 • ↑/↓: 選択", + "Sending…": "送信中…", + "Text sent; check Codex for the outcome.": "テキストを送信しました。Codex で結果を確認してください。", + "Send unconfirmed; check Codex before retrying.": "送信未確認。再試行前に Codex を確認してください。", + "Prompt changed; review the session before replying.": "プロンプトが変わりました。回答前にセッションを確認してください。", + "Choose an offered answer with ↑/↓.": "↑/↓ で提示された回答を選んでください。", + "OPEN DETAIL": "詳細を開く", + "No supported approval choices were offered.": "対応する承認の選択肢がありません。", + "[ ALLOW FOR SESSION ]": "[ セッション中は許可 ]", + "[ CONFIRM SESSION GRANT ]": "[ セッション許可を確定 ]", + "[ ALWAYS ALLOW PREFIX ]": "[ 接頭辞を常に許可 ]", + "[ CONFIRM PERSISTENT RULE ]": "[ 永続ルールを確定 ]", + "[ REJECT & STOP TURN ]": "[ 拒否してターンを停止 ]", + "OFFERED DECISIONS": "提示された選択肢", + "Questions must be answered in Codex.": "質問には Codex で回答してください。", + "Local observation cannot answer live approvals.": "ローカル監視ではライブ承認に応答できません。", + "Network approval is not supported here.": "ネットワーク承認には対応していません。", + "Additional permissions require approval in Codex.": "追加の権限は Codex で承認してください。", + "File-change approval is not supported here.": "ファイル変更の承認には対応していません。", + "Unsupported command format.": "未対応のコマンド形式です。", + "Command details are missing.": "コマンドの詳細がありません。", + "Working directory is missing.": "作業ディレクトリがありません。", + "Turn or item identity is missing.": "ターンまたは項目の識別情報がありません。", + "One-time approval and decline are not both offered.": "今回のみの承認と拒否の両方が提供されていません。", + "Request details exceed the display limit.": "要求の詳細が表示上限を超えています。", + "Request text was altered for safe display.": "安全に表示するため要求のテキストが変更されました。", + "No actionable live approval was received.": "応答可能なライブ承認要求を受信していません。", + "Request resolved, expired, or disconnected.": "要求は解決済み、期限切れ、または接続が切断されました。", + "Enlarge the terminal to show approval controls.": "承認ボタンを表示するには端末を広げてください。", "[ APPROVE ONCE ]": "[ 今回のみ許可 ]", "[ CONFIRM APPROVAL ]": "[ 許可を確定 ]", "[ DECLINE ]": "[ 拒否 ]", "Sending decision…": "決定を送信中…", - "Decision sent; check Codex for the outcome.": "決定を送信しました。結果は Codex で確認してください。", + "Decision sent…": "決定を送信しました…", "Decision unconfirmed; check Codex. Do not retry here.": "決定を確認できません。Codex を確認し、ここでは再試行しないでください。", "LAST REPLY": "直近の返信", "QUESTION": "質問", diff --git a/internal/i18n/locales/nl.json b/internal/i18n/locales/nl.json index b00ff98..26dba5d 100644 --- a/internal/i18n/locales/nl.json +++ b/internal/i18n/locales/nl.json @@ -1,9 +1,41 @@ { + "FOLLOW-UP": "VERVOLG", + "REPLY": "ANTWOORD", + "[ Click here or press Enter to write ]": "[ Klik hier of druk op Enter om te typen ]", + "Enter: send / next answer • Esc: leave editor • ↑/↓: choices": "Enter: verzenden / volgende • Esc: editor verlaten • ↑/↓: keuzes", + "Sending…": "Verzenden…", + "Text sent; check Codex for the outcome.": "Tekst verzonden; bekijk het resultaat in Codex.", + "Send unconfirmed; check Codex before retrying.": "Verzending onbevestigd; controleer Codex vóór opnieuw proberen.", + "Prompt changed; review the session before replying.": "Prompt gewijzigd; controleer de sessie vóór antwoorden.", + "Choose an offered answer with ↑/↓.": "Kies een aangeboden antwoord met ↑/↓.", + "OPEN DETAIL": "DETAIL OPENEN", + "No supported approval choices were offered.": "Er zijn geen ondersteunde goedkeuringsopties aangeboden.", + "[ ALLOW FOR SESSION ]": "[ TOESTAAN VOOR SESSIE ]", + "[ CONFIRM SESSION GRANT ]": "[ SESSIERECHTEN BEVESTIGEN ]", + "[ ALWAYS ALLOW PREFIX ]": "[ PREFIX ALTIJD TOESTAAN ]", + "[ CONFIRM PERSISTENT RULE ]": "[ VASTE REGEL BEVESTIGEN ]", + "[ REJECT & STOP TURN ]": "[ WEIGEREN EN BEURT STOPPEN ]", + "OFFERED DECISIONS": "AANGEBODEN OPTIES", + "Questions must be answered in Codex.": "Beantwoord vragen in Codex.", + "Local observation cannot answer live approvals.": "Lokale observatie kan geen live goedkeuring geven.", + "Network approval is not supported here.": "Netwerkgoedkeuring wordt hier niet ondersteund.", + "Additional permissions require approval in Codex.": "Extra rechten vereisen goedkeuring in Codex.", + "File-change approval is not supported here.": "Goedkeuring van bestandswijzigingen wordt hier niet ondersteund.", + "Unsupported command format.": "Niet-ondersteund opdrachtformaat.", + "Command details are missing.": "Opdrachtgegevens ontbreken.", + "Working directory is missing.": "Werkmap ontbreekt.", + "Turn or item identity is missing.": "Beurt- of itemidentiteit ontbreekt.", + "One-time approval and decline are not both offered.": "Eenmalig goedkeuren en weigeren worden niet beide aangeboden.", + "Request details exceed the display limit.": "De aanvraag overschrijdt de weergavelimiet.", + "Request text was altered for safe display.": "Aanvraagtekst is aangepast voor veilige weergave.", + "No actionable live approval was received.": "Geen uitvoerbare live goedkeuring ontvangen.", + "Request resolved, expired, or disconnected.": "Aanvraag afgehandeld, verlopen of verbinding verbroken.", + "Enlarge the terminal to show approval controls.": "Vergroot de terminal voor goedkeuringsknoppen.", "[ APPROVE ONCE ]": "[ EENMALIG GOEDKEUREN ]", "[ CONFIRM APPROVAL ]": "[ GOEDKEURING BEVESTIGEN ]", "[ DECLINE ]": "[ WEIGEREN ]", "Sending decision…": "Beslissing verzenden…", - "Decision sent; check Codex for the outcome.": "Beslissing verzonden; bekijk het resultaat in Codex.", + "Decision sent…": "Beslissing verzonden…", "Decision unconfirmed; check Codex. Do not retry here.": "Beslissing niet bevestigd; controleer Codex. Probeer hier niet opnieuw.", "LAST REPLY": "LAATSTE ANTWOORD", "QUESTION": "VRAAG", diff --git a/internal/i18n/locales/ru.json b/internal/i18n/locales/ru.json index 086d553..6dd9488 100644 --- a/internal/i18n/locales/ru.json +++ b/internal/i18n/locales/ru.json @@ -1,9 +1,41 @@ { + "FOLLOW-UP": "ПРОДОЛЖЕНИЕ", + "REPLY": "ОТВЕТ", + "[ Click here or press Enter to write ]": "[ Нажмите здесь или Enter для ввода ]", + "Enter: send / next answer • Esc: leave editor • ↑/↓: choices": "Enter: отправить / далее • Esc: выйти • ↑/↓: варианты", + "Sending…": "Отправка…", + "Text sent; check Codex for the outcome.": "Текст отправлен; проверьте результат в Codex.", + "Send unconfirmed; check Codex before retrying.": "Отправка не подтверждена; проверьте Codex перед повтором.", + "Prompt changed; review the session before replying.": "Запрос изменился; проверьте сессию перед ответом.", + "Choose an offered answer with ↑/↓.": "Выберите предложенный ответ с помощью ↑/↓.", + "OPEN DETAIL": "ОТКРЫТЬ ПОДРОБНОСТИ", + "No supported approval choices were offered.": "Поддерживаемые варианты разрешения не предложены.", + "[ ALLOW FOR SESSION ]": "[ РАЗРЕШИТЬ ДЛЯ СЕАНСА ]", + "[ CONFIRM SESSION GRANT ]": "[ ПОДТВЕРДИТЬ ДЛЯ СЕАНСА ]", + "[ ALWAYS ALLOW PREFIX ]": "[ ВСЕГДА РАЗРЕШАТЬ ПРЕФИКС ]", + "[ CONFIRM PERSISTENT RULE ]": "[ ПОДТВЕРДИТЬ ПОСТОЯННОЕ ПРАВИЛО ]", + "[ REJECT & STOP TURN ]": "[ ОТКЛОНИТЬ И ОСТАНОВИТЬ ХОД ]", + "OFFERED DECISIONS": "ПРЕДЛОЖЕННЫЕ ВАРИАНТЫ", + "Questions must be answered in Codex.": "На вопросы нужно отвечать в Codex.", + "Local observation cannot answer live approvals.": "Локальное наблюдение не позволяет отвечать на запросы разрешений.", + "Network approval is not supported here.": "Разрешения сети здесь не поддерживаются.", + "Additional permissions require approval in Codex.": "Дополнительные разрешения нужно подтвердить в Codex.", + "File-change approval is not supported here.": "Подтверждение изменений файлов здесь не поддерживается.", + "Unsupported command format.": "Неподдерживаемый формат команды.", + "Command details are missing.": "Нет сведений о команде.", + "Working directory is missing.": "Не указан рабочий каталог.", + "Turn or item identity is missing.": "Нет идентификатора хода или элемента.", + "One-time approval and decline are not both offered.": "Разовое разрешение и отказ не предложены одновременно.", + "Request details exceed the display limit.": "Сведения о запросе превышают лимит отображения.", + "Request text was altered for safe display.": "Текст запроса изменён для безопасного отображения.", + "No actionable live approval was received.": "Не получен доступный для ответа запрос разрешения.", + "Request resolved, expired, or disconnected.": "Запрос разрешён, истёк или соединение разорвано.", + "Enlarge the terminal to show approval controls.": "Увеличьте терминал для отображения кнопок разрешения.", "[ APPROVE ONCE ]": "[ РАЗРЕШИТЬ ОДИН РАЗ ]", "[ CONFIRM APPROVAL ]": "[ ПОДТВЕРДИТЬ РАЗРЕШЕНИЕ ]", "[ DECLINE ]": "[ ОТКЛОНИТЬ ]", "Sending decision…": "Отправка решения…", - "Decision sent; check Codex for the outcome.": "Решение отправлено; проверьте результат в Codex.", + "Decision sent…": "Решение отправлено…", "Decision unconfirmed; check Codex. Do not retry here.": "Решение не подтверждено; проверьте Codex. Не повторяйте здесь.", "LAST REPLY": "ПОСЛЕДНИЙ ОТВЕТ", "QUESTION": "ВОПРОС", diff --git a/internal/i18n/locales/zh-Hans.json b/internal/i18n/locales/zh-Hans.json index 13a46c2..c10affd 100644 --- a/internal/i18n/locales/zh-Hans.json +++ b/internal/i18n/locales/zh-Hans.json @@ -1,9 +1,41 @@ { + "FOLLOW-UP": "继续提问", + "REPLY": "回复", + "[ Click here or press Enter to write ]": "[ 点击此处或按 Enter 输入 ]", + "Enter: send / next answer • Esc: leave editor • ↑/↓: choices": "Enter:发送/下一题 • Esc:退出编辑 • ↑/↓:选项", + "Sending…": "正在发送…", + "Text sent; check Codex for the outcome.": "文本已发送;请在 Codex 中查看结果。", + "Send unconfirmed; check Codex before retrying.": "发送未确认;重试前请检查 Codex。", + "Prompt changed; review the session before replying.": "提示已更改;回复前请检查会话。", + "Choose an offered answer with ↑/↓.": "使用 ↑/↓ 选择提供的答案。", + "OPEN DETAIL": "打开详情", + "No supported approval choices were offered.": "未提供支持的批准选项。", + "[ ALLOW FOR SESSION ]": "[ 本会话允许 ]", + "[ CONFIRM SESSION GRANT ]": "[ 确认会话授权 ]", + "[ ALWAYS ALLOW PREFIX ]": "[ 始终允许此前缀 ]", + "[ CONFIRM PERSISTENT RULE ]": "[ 确认永久规则 ]", + "[ REJECT & STOP TURN ]": "[ 拒绝并停止轮次 ]", + "OFFERED DECISIONS": "提供的选项", + "Questions must be answered in Codex.": "请在 Codex 中回答问题。", + "Local observation cannot answer live approvals.": "本地观察无法响应实时批准请求。", + "Network approval is not supported here.": "此处不支持网络批准。", + "Additional permissions require approval in Codex.": "额外权限需要在 Codex 中批准。", + "File-change approval is not supported here.": "此处不支持文件更改批准。", + "Unsupported command format.": "不支持的命令格式。", + "Command details are missing.": "缺少命令详情。", + "Working directory is missing.": "缺少工作目录。", + "Turn or item identity is missing.": "缺少轮次或项目标识。", + "One-time approval and decline are not both offered.": "未同时提供单次批准和拒绝选项。", + "Request details exceed the display limit.": "请求详情超出显示限制。", + "Request text was altered for safe display.": "请求文本已为安全显示而修改。", + "No actionable live approval was received.": "未收到可操作的实时批准请求。", + "Request resolved, expired, or disconnected.": "请求已解决、过期或连接已断开。", + "Enlarge the terminal to show approval controls.": "请扩大终端以显示批准按钮。", "[ APPROVE ONCE ]": "[ 仅允许一次 ]", "[ CONFIRM APPROVAL ]": "[ 确认批准 ]", "[ DECLINE ]": "[ 拒绝 ]", "Sending decision…": "正在发送决定…", - "Decision sent; check Codex for the outcome.": "决定已发送;请在 Codex 中查看结果。", + "Decision sent…": "决定已发送…", "Decision unconfirmed; check Codex. Do not retry here.": "决定未确认;请检查 Codex。不要在此重试。", "LAST REPLY": "最近回复", "QUESTION": "问题", diff --git a/internal/ui/localisation_test.go b/internal/ui/localisation_test.go index 608176b..73aa7a5 100644 --- a/internal/ui/localisation_test.go +++ b/internal/ui/localisation_test.go @@ -49,6 +49,8 @@ func TestLocalisedScreensHelper(t *testing.T) { t.Run("monitor_context_surfaces", TestMonitorContextResponsiveHitTargets) t.Run("monitor_privacy_surfaces", TestMonitorContextPrivacyAndDismissRenderedTargets) t.Run("monitor_approval_surfaces", TestMonitorApprovalRenderedTargets) + t.Run("monitor_expanded_surfaces", TestExpandedContextResponsiveHitTargets) + t.Run("monitor_prompt_surfaces", TestMonitorPromptResponsiveClickTargets) t.Run("tab_click_surfaces", TestEveryRenderedTabCellIsClickableAcrossWidths) t.Run("reset_click_surfaces", TestQuotaResetRenderedHitSurfaces) t.Run("quota_estimator", TestQuotaAPIEstimatorLearnsRangeAndCurrentSpend) diff --git a/internal/ui/model.go b/internal/ui/model.go index eab6c8e..45df3fd 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -43,8 +43,10 @@ type BenchmarkTaskProvider interface { } type Model struct { + monitorPrompt monitorPromptState monitorContextHidden bool monitorContextDetail string + monitorContextExpanded string monitorContextScroll int monitorContextHover string monitorApprovalConfirm string @@ -420,15 +422,20 @@ func (m Model) Init() tea.Cmd { } func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) { + if next, cmd, handled := m.updateMonitorPrompt(message); handled { + return next, cmd + } else { + m = next + } switch message := message.(type) { case monitorApprovalResult: m.monitorApprovalBusy = false m.monitorApprovalConfirm = "" - if m.monitorContextDetail != message.sessionID { + if m.monitorContextTarget() != message.sessionID { m.monitorApprovalNotice = "" return m, nil } - m.monitorApprovalNotice = i18n.Text("Decision sent; check Codex for the outcome.") + m.monitorApprovalNotice = i18n.Text("Decision sent…") if message.err != nil { m.monitorApprovalNotice = i18n.Text("Decision unconfirmed; check Codex. Do not retry here.") } @@ -1037,6 +1044,7 @@ func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) { func (m Model) pressViewTab(view meterViewID) (tea.Model, tea.Cmd) { if view != viewMonitor { m.monitorContextDetail = "" + m.monitorContextExpanded = "" m.monitorContextHover = "" } m.resetConfirmUntil = time.Time{} @@ -2128,6 +2136,7 @@ func (m Model) monitorHasVisibleWaitingSession() bool { func (m *Model) resetMonitorFromSnapshot(message monitorFetchedMsg, paused bool) { m.monitorContextDetail = "" + m.monitorContextExpanded = "" m.monitorContextScroll = 0 m.monitorStartedAt = message.at m.monitorStoppedAt = time.Time{} @@ -2487,6 +2496,11 @@ func (m *Model) syncMonitorSessions(usage codex.LiveUsageSnapshot, observedAt ti } func (m *Model) dismissMonitorSession(id string) { + if id == m.monitorContextTarget() { + m.monitorContextDetail = "" + m.monitorContextExpanded = "" + m.monitorApprovalConfirm = "" + } index := m.monitorSessionIndex(id) if index < 0 { return @@ -2684,6 +2698,12 @@ func (m *Model) selectMonitorSession(direction int) { selected = min(max(selected+direction, 0), len(visible)-1) } m.monitorSelectedID = visible[selected] + if m.monitorContextExpanded != "" && m.monitorContextExpanded != m.monitorSelectedID { + m.monitorContextExpanded = "" + m.monitorApprovalConfirm = "" + m.monitorApprovalNotice = "" + m.monitorPrompt = monitorPromptState{} + } pageSize := max(m.monitorPageSize(), 1) if selected < m.monitorScroll { m.monitorScroll = selected diff --git a/internal/ui/monitor.go b/internal/ui/monitor.go index cc8bc0c..ae294b4 100644 --- a/internal/ui/monitor.go +++ b/internal/ui/monitor.go @@ -244,6 +244,21 @@ func (m Model) monitorSessionPage(height int) ([]monitorSession, []int, string) visibleCount := len(visible) rowCount := min(visibleCount, max(height/3, 1)) start := min(max(m.monitorScroll, 0), max(visibleCount-rowCount, 0)) + // Keep the expanded target on screen as new sessions arrive. It never + // follows a newer approval to a different row implicitly. + if m.monitorContextExpanded != "" && !m.monitorContextHidden { + for i, s := range visible { + if s.id == m.monitorContextExpanded { + if i < start { + start = i + } + if i >= start+rowCount { + start = i - rowCount + 1 + } + break + } + } + } visible = visible[start : start+rowCount] pageLabel := "" if rowCount < visibleCount { @@ -257,7 +272,7 @@ func (m Model) monitorSessionPage(height int) ([]monitorSession, []int, string) func (m Model) renderMonitorSessionRow(width, height int, session monitorSession, pageLabel string, colors palette) string { rowColors := colors - if session.id == m.monitorSelectedID { + if session.id == m.monitorSelectedID || session.id == m.monitorContextExpanded { rowColors.primary = colors.accent } metricsWidth, graphWidth, ok := monitorSessionColumnWidths(width) @@ -265,7 +280,7 @@ func (m Model) renderMonitorSessionRow(width, height int, session monitorSession return m.renderMonitorGraphSamples(width, height, session.samples, i18n.Text("TOKENS"), rowColors) } metrics := m.renderMonitorSessionMetrics(metricsWidth, height, session, pageLabel, rowColors) - if !m.monitorContextHidden && session.preview.Text != "" { + if !m.monitorContextHidden && (session.preview.Text != "" || m.monitorContextExpanded == session.id || m.monitorContextActionVisible(session)) { return m.renderMonitorContextRow(width, height, metrics, session, rowColors) } title := i18n.Text("TOKEN BARS") @@ -303,9 +318,6 @@ func (m Model) renderMonitorSessionMetrics(width, height int, session monitorSes } if session.attention != codex.SessionAttentionNone { status = monitorAttentionStatus(session.attention) - if session.preview.Kind == codex.SessionContextReply && session.attention == codex.SessionAttentionInput { - status = i18n.Text("TURN COMPLETE") - } } innerWidth := max(width-4, 1) memberLabel := "ROOT" @@ -391,6 +403,8 @@ func monitorSessionDismissRect(metricsWidth, rowY int) (monitorRect, bool) { func monitorAttentionLabel(attention codex.SessionAttention) string { switch attention { + case codex.SessionAttentionComplete: + return i18n.Text("TURN COMPLETE") case codex.SessionAttentionApproval: return i18n.Text("APPROVAL NEEDED") case codex.SessionAttentionCheck: @@ -402,6 +416,8 @@ func monitorAttentionLabel(attention codex.SessionAttention) string { func monitorAttentionStatus(attention codex.SessionAttention) string { switch attention { + case codex.SessionAttentionComplete: + return i18n.Text("TURN COMPLETE") case codex.SessionAttentionApproval: return i18n.Text("APPROVAL") case codex.SessionAttentionCheck: diff --git a/internal/ui/monitor_approval.go b/internal/ui/monitor_approval.go index 1890640..bbf9c8f 100644 --- a/internal/ui/monitor_approval.go +++ b/internal/ui/monitor_approval.go @@ -2,6 +2,8 @@ package ui import ( "context" + "strconv" + "strings" "time" tea "charm.land/bubbletea/v2" @@ -15,6 +17,47 @@ type monitorApprovalResult struct { err error } +func (m Model) monitorApprovalBlockReason(c codex.SessionContext) string { + if m.monitorApprovalBusy { + return i18n.Text("Sending decision…") + } + if c.Kind == codex.SessionContextQuestion { + return i18n.Text("Questions must be answered in Codex.") + } + if c.Source == "LOCAL" { + return i18n.Text("Local observation cannot answer live approvals.") + } + switch c.ApprovalBlocked { + case "network": + return i18n.Text("Network approval is not supported here.") + case "permissions": + return i18n.Text("Additional permissions require approval in Codex.") + case "file-change": + return i18n.Text("File-change approval is not supported here.") + case "command-format": + return i18n.Text("Unsupported command format.") + case "missing-command": + return i18n.Text("Command details are missing.") + case "missing-directory": + return i18n.Text("Working directory is missing.") + case "missing-identity": + return i18n.Text("Turn or item identity is missing.") + case "decisions": + return i18n.Text("No supported approval choices were offered.") + case "truncated": + return i18n.Text("Request details exceed the display limit.") + case "sanitised": + return i18n.Text("Request text was altered for safe display.") + } + if c.ApprovalToken == "" { + return i18n.Text("No actionable live approval was received.") + } + if m.monitorApprovalToken() == "" { + return i18n.Text("Request resolved, expired, or disconnected.") + } + return i18n.Text("Enlarge the terminal to show approval controls.") +} + func (m Model) monitorApprovalToken() string { if m.monitorContextHidden || m.monitorApprovalBusy { return "" @@ -27,21 +70,97 @@ func (m Model) monitorApprovalToken() string { return s.preview.ApprovalToken } -func (m Model) monitorApprovalLabels() (string, string) { - a := i18n.Text("[ APPROVE ONCE ]") - if token := m.monitorApprovalToken(); token != "" && m.monitorApprovalConfirm == token { - a = i18n.Text("[ CONFIRM APPROVAL ]") +func approvalOptionLabel(kind string, confirm bool) string { + switch kind { + case "accept": + if confirm { + return i18n.Text("[ CONFIRM APPROVAL ]") + } + return i18n.Text("[ APPROVE ONCE ]") + case "acceptForSession": + if confirm { + return i18n.Text("[ CONFIRM SESSION GRANT ]") + } + return i18n.Text("[ ALLOW FOR SESSION ]") + case "acceptWithExecpolicyAmendment": + if confirm { + return i18n.Text("[ CONFIRM PERSISTENT RULE ]") + } + return i18n.Text("[ ALWAYS ALLOW PREFIX ]") + case "decline": + return i18n.Text("[ DECLINE ]") + case "cancel": + return i18n.Text("[ REJECT & STOP TURN ]") + } + return "" +} + +type monitorApprovalButton struct { + action, label string + x, y, slot int +} + +// Rendering and hit testing share this layout. Slots reserve confirmation +// widths so neighbouring decisions never move underneath a user's pointer. +func (m Model) monitorApprovalButtons(width, height int) []monitorApprovalButton { + token := m.monitorApprovalToken() + if token == "" { + return nil + } + s, ok := m.contextDetailSession() + if !ok { + return nil + } + var buttons []monitorApprovalButton + x, y := 0, 0 + for i, option := range s.preview.ApprovalOptions { + if option.Kind == "" { + continue + } + label := approvalOptionLabel(option.Kind, false) + slot := max(lipgloss.Width(label), lipgloss.Width(approvalOptionLabel(option.Kind, true))) + if slot > width-4 || label == "" { + return nil + } + if x+slot > width-4 { + x = 0 + y++ + } + if y+1 > height-5 { + return nil + } + action := "decision:" + strconv.Itoa(i) + if m.monitorApprovalConfirm == token+"/"+action { + label = approvalOptionLabel(option.Kind, true) + } + buttons = append(buttons, monitorApprovalButton{action, label, x, y, slot}) + x += slot + 2 } - return a, i18n.Text("[ DECLINE ]") + return buttons } func (m Model) monitorApprovalControls(width, height int) bool { - _, b := m.monitorApprovalLabels() - return height >= 6 && width-4 >= monitorApprovalSlotWidth()+lipgloss.Width(b)+2 && m.monitorApprovalToken() != "" + return len(m.monitorApprovalButtons(width, height)) > 0 +} + +func (m Model) monitorApprovalControlRows(width, height int) int { + b := m.monitorApprovalButtons(width, height) + if len(b) == 0 { + return 0 + } + return b[len(b)-1].y + 1 } -func monitorApprovalSlotWidth() int { - return max(lipgloss.Width(i18n.Text("[ APPROVE ONCE ]")), lipgloss.Width(i18n.Text("[ CONFIRM APPROVAL ]"))) +func (m Model) renderMonitorApprovalControls(width, height int, colors palette) string { + buttons := m.monitorApprovalButtons(width, height) + if len(buttons) == 0 { + return "" + } + rows := make([]string, buttons[len(buttons)-1].y+1) + for _, b := range buttons { + rows[b.y] += strings.Repeat(" ", max(b.x-lipgloss.Width(rows[b.y]), 0)) + m.renderContextAction(b.action, b.label, colors) + } + return strings.Join(rows, "\n") } func (m Model) monitorApprovalAction(action string) (Model, tea.Cmd, bool) { @@ -50,19 +169,25 @@ func (m Model) monitorApprovalAction(action string) (Model, tea.Cmd, bool) { m.monitorApprovalConfirm = "" return m, nil, true } - if action == "approve" && m.monitorApprovalConfirm != token { - m.monitorApprovalConfirm = token + index, err := strconv.Atoi(strings.TrimPrefix(action, "decision:")) + s, ok := m.contextDetailSession() + if err != nil || !strings.HasPrefix(action, "decision:") || !ok || index < 0 || index >= len(s.preview.ApprovalOptions) { return m, nil, true } - decision := "decline" - if action == "approve" { - decision = "accept" + option := s.preview.ApprovalOptions[index] + if option.Kind == "" { + return m, nil, true + } + if option.GrantsPermission() && m.monitorApprovalConfirm != token+"/"+action { + m.monitorApprovalConfirm = token + "/" + action + return m, nil, true } + decision := option.Value m.monitorApprovalConfirm = "" m.monitorApprovalBusy = true m.monitorApprovalNotice = i18n.Text("Sending decision…") p := m.fetcher.(codex.SessionApprovalClient) - id := m.monitorContextDetail + id := m.monitorContextTarget() return m, func() tea.Msg { ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() diff --git a/internal/ui/monitor_approval_test.go b/internal/ui/monitor_approval_test.go index dc12124..d4a6057 100644 --- a/internal/ui/monitor_approval_test.go +++ b/internal/ui/monitor_approval_test.go @@ -2,6 +2,7 @@ package ui import ( "context" + "strconv" "strings" "testing" @@ -9,6 +10,7 @@ import ( "charm.land/lipgloss/v2" "github.com/charmbracelet/x/ansi" "github.com/merefield/codexometer/internal/codex" + "github.com/merefield/codexometer/internal/i18n" ) type approvalTestClient struct{ token, decision string } @@ -29,6 +31,9 @@ func approvalTestModel() Model { m := contextTestModel() m.fetcher = &approvalTestClient{token: "live"} m.monitorSessionData[0].preview = codex.SessionContext{Kind: codex.SessionContextApproval, Text: "Command: git push\nDirectory: /work", Source: "LIVE", ApprovalToken: "live"} + m.monitorSessionData[0].preview.ApprovalOptions = [8]codex.ApprovalOption{ + {Kind: "accept", Value: "accept"}, {Kind: "decline", Value: "decline"}, {Kind: "cancel", Value: "cancel"}, {Kind: "acceptForSession", Value: "acceptForSession"}, {Kind: "acceptWithExecpolicyAmendment", Value: `{"acceptWithExecpolicyAmendment":{"execpolicy_amendment":["pwd"]}}`}, + } m.openMonitorContext(m.monitorSessionData[0].id) return m } @@ -41,24 +46,28 @@ func TestMonitorApprovalRenderedTargets(t *testing.T) { m.width = width m.height = height if confirmed { - m.monitorApprovalConfirm = "live" + m.monitorApprovalConfirm = "live/decision:0" } out := m.render() if lipgloss.Width(out) > width || lipgloss.Height(out) > height { t.Fatalf("overflow %dx%d", width, height) } - a, b := m.monitorApprovalLabels() g := m.dashboardLayout() shown := m.monitorApprovalControls(g.contentWidth, g.meterHeight) - for label, action := range map[string]string{a: "approve", b: "decline"} { + for i, option := range m.monitorSessionData[0].preview.ApprovalOptions { + if option.Kind == "" { + continue + } + action := "decision:" + strconv.Itoa(i) + label := approvalOptionLabel(option.Kind, confirmed && i == 0) found := false for y, line := range strings.Split(ansi.Strip(out), "\n") { - i := strings.Index(line, label) - if i < 0 { + pos := strings.Index(line, label) + if pos < 0 { continue } found = true - x := lipgloss.Width(line[:i]) + x := lipgloss.Width(line[:pos]) for dx := 0; dx < lipgloss.Width(label); dx++ { if got := m.monitorContextAt(x+dx, y); got != action { t.Fatalf("%s miss at %d,%d got %q\n%s", action, x+dx, y, got, ansi.Strip(out)) @@ -66,8 +75,8 @@ func TestMonitorApprovalRenderedTargets(t *testing.T) { } updated, cmd := m.Update(tea.MouseClickMsg(tea.Mouse{X: x, Y: y, Button: tea.MouseLeft})) u := updated.(Model) - if action == "approve" && !confirmed { - if cmd != nil || u.monitorApprovalConfirm != "live" { + if option.GrantsPermission() && !(confirmed && i == 0) { + if cmd != nil || u.monitorApprovalConfirm != "live/"+action { t.Fatal("first click must only confirm") } } else if cmd == nil || !u.monitorApprovalBusy { @@ -85,13 +94,13 @@ func TestMonitorApprovalRenderedTargets(t *testing.T) { func TestMonitorApprovalStaleAndKeyboardSafety(t *testing.T) { m := approvalTestModel() - m, cmd, _ := m.monitorApprovalAction("approve") + m, cmd, _ := m.monitorApprovalAction("decision:0") if cmd != nil { t.Fatal("first click sent decision") } client := m.fetcher.(*approvalTestClient) client.token = "replacement" - m, cmd, _ = m.monitorApprovalAction("approve") + m, cmd, _ = m.monitorApprovalAction("decision:0") if cmd != nil || m.monitorApprovalConfirm != "" { t.Fatal("stale confirmation submitted") } @@ -113,3 +122,103 @@ func TestMonitorApprovalStaleAndKeyboardSafety(t *testing.T) { t.Fatal("local request actionable") } } + +func TestMonitorApprovalExplainsUnavailableControls(t *testing.T) { + m := approvalTestModel() + s := &m.monitorSessionData[0] + s.preview.ApprovalToken = "" + s.preview.ApprovalBlocked = "permissions" + lines := m.contextDetailLines(300) + if !strings.Contains(strings.Join(lines, "\n"), i18n.Text("Additional permissions require approval in Codex.")) { + t.Fatal("missing explanation", lines) + } + for _, line := range m.contextDetailLines(20) { + if lipgloss.Width(line) > 20 { + t.Fatal("diagnostic overflow", line) + } + } + s.preview.ApprovalBlocked = "" + s.preview.Source = "LOCAL" + if got := m.monitorApprovalBlockReason(s.preview); got != i18n.Text("Local observation cannot answer live approvals.") { + t.Fatal(got) + } + s.preview.Source = "LIVE" + s.preview.ApprovalToken = "expired" + if got := m.monitorApprovalBlockReason(s.preview); got != i18n.Text("Request resolved, expired, or disconnected.") { + t.Fatal(got) + } + s.preview.ApprovalToken = "live" + m.width = 30 + if got := strings.Join(m.contextDetailLines(100), "\n"); !strings.Contains(got, i18n.Text("Enlarge the terminal to show approval controls.")) { + t.Fatal(got) + } +} + +func TestMonitorGrantsConfirmExactChoiceAndStableLayout(t *testing.T) { + for _, index := range []int{0, 3, 4} { + m := approvalTestModel() + m.width = 180 + m.height = 40 + g := m.dashboardLayout() + before := m.monitorApprovalButtons(g.contentWidth, g.meterHeight) + action := "decision:" + strconv.Itoa(index) + m, cmd, _ := m.monitorApprovalAction(action) + if cmd != nil { + t.Fatal("grant sent without confirmation") + } + after := m.monitorApprovalButtons(g.contentWidth, g.meterHeight) + for i := range before { + if before[i].x != after[i].x || before[i].y != after[i].y { + t.Fatal("confirmation moved decision targets") + } + } + m, cmd, _ = m.monitorApprovalAction(action) + if cmd == nil { + t.Fatal("confirmed grant not queued") + } + cmd() + if got := m.fetcher.(*approvalTestClient).decision; got != m.monitorSessionData[0].preview.ApprovalOptions[index].Value { + t.Fatal("wrong decision sent", got) + } + } + m := approvalTestModel() + m, _, _ = m.monitorApprovalAction("decision:0") + m, cmd, _ := m.monitorApprovalAction("decision:3") + if cmd != nil || m.monitorApprovalConfirm != "live/decision:3" { + t.Fatal("one-time confirmation authorised session grant") + } +} + +func TestMonitorApprovalFooterPinnedBelowScrollingText(t *testing.T) { + m := approvalTestModel() + m.width = 120 + m.height = 30 + m.monitorSessionData[0].preview.Text = strings.Repeat("request detail\n", 100) + "END OF REQUEST" + g := m.dashboardLayout() + n := m.monitorApprovalControlRows(g.contentWidth, g.meterHeight) + textRows, gap, y := monitorContextBodyLayout(g.meterHeight, n) + if n == 0 || gap != 1 || textRows < 3 { + t.Fatal("footer allocation", n, textRows, gap) + } + for _, offset := range []int{0, 10000} { + m.scrollMonitorContext(offset) + out := strings.Split(ansi.Strip(m.renderMonitorContextDetail(g.contentWidth, g.meterHeight, paletteFor(m.theme))), "\n") + if strings.Trim(out[y-1], " │") != "" { + t.Fatal("missing spacer", out[y-1]) + } + for _, b := range m.monitorApprovalButtons(g.contentWidth, g.meterHeight) { + if !strings.Contains(out[y+b.y], b.label) { + t.Fatal("button moved while scrolling") + } + if got := m.monitorContextAt(4+b.x, g.meterY+y+b.y); got != b.action { + t.Fatal("footer hit miss", got) + } + } + if offset > 0 && !strings.Contains(strings.Join(out[:y], "\n"), "END OF REQUEST") { + t.Fatal("last text row inaccessible") + } + } + if rows, gap, _ := monitorContextBodyLayout(6, 1); rows != 3 || gap != 0 { + t.Fatal("short terminal did not drop padding first") + } +} diff --git a/internal/ui/monitor_context.go b/internal/ui/monitor_context.go index 5ac88e0..9186f24 100644 --- a/internal/ui/monitor_context.go +++ b/internal/ui/monitor_context.go @@ -14,9 +14,6 @@ import ( const monitorContextInfo = "[i]" func monitorSessionAttentionLabel(s monitorSession) string { - if s.attention == codex.SessionAttentionInput && s.preview.Kind == codex.SessionContextReply { - return i18n.Text("TURN COMPLETE") - } return monitorAttentionLabel(s.attention) } @@ -57,7 +54,7 @@ func contextActionRect(width, y int, label string) (monitorRect, bool) { // space previously owned by the graph; narrow terminals prioritise the text. func contextColumns(width int, s monitorSession) (metrics, context, graph int) { metrics, graph, _ = monitorSessionColumnWidths(width) - if s.preview.Kind == codex.SessionContextActivity && s.attention == codex.SessionAttentionNone { + if s.preview.Text == "" || s.preview.Kind == codex.SessionContextActivity && s.attention == codex.SessionAttentionNone { return } if width >= 100 { @@ -77,8 +74,15 @@ func contextAge(c codex.SessionContext) string { } func (m Model) renderMonitorContextRow(width, height int, metrics string, s monitorSession, colors palette) string { + if m.monitorContextExpanded == s.id { + _, cw, _ := monitorSessionColumnWidths(width) + return lipgloss.JoinHorizontal(lipgloss.Top, metrics, " ", m.renderExpandedContext(cw, height, s, colors)) + } _, cw, gw := contextColumns(width, s) - info := m.renderContextAction(s.id, monitorContextInfo, colors) + info := "" + if m.monitorContextActionVisible(s) { + info = m.renderContextAction(s.id, monitorContextInfo, colors) + } if cw == 0 { graph := m.renderMonitorGraphWithAction(gw, height, s.samples, i18n.Text("TOKEN BARS"), info, colors) return lipgloss.JoinHorizontal(lipgloss.Top, metrics, " ", graph) @@ -109,7 +113,7 @@ func (m Model) renderMonitorContextRow(width, height int, metrics string, s moni func (m Model) contextDetailSession() (monitorSession, bool) { for _, s := range m.monitorSessionData { - if s.id == m.monitorContextDetail && m.monitorSessionVisible(s) { + if s.id == m.monitorContextTarget() && m.monitorSessionVisible(s) { return s, true } } @@ -123,8 +127,13 @@ func (m Model) contextDetailLines(width int) []string { } header := contextTitle(s.preview) + " // " + shortSessionID(s.preview.ThreadID) + " // " + s.preview.Source + " // " + contextAge(s.preview) lines := []string{ansi.Truncate(header, max(width, 1), "")} - if (s.preview.Kind == codex.SessionContextApproval || s.preview.Kind == codex.SessionContextQuestion) && m.monitorApprovalToken() == "" { - lines = append(lines, ansi.Truncate(i18n.Text("REPLY IN CODEX"), max(width, 1), "")) + g := m.dashboardLayout() + if (s.preview.Kind == codex.SessionContextApproval || s.preview.Kind == codex.SessionContextQuestion) && !m.monitorApprovalControls(g.contentWidth, g.meterHeight) && m.monitorPromptRows(g.contentWidth, g.meterHeight) == 0 { + message := i18n.Text("REPLY IN CODEX") + " // " + m.monitorApprovalBlockReason(s.preview) + lines = append(lines, strings.Split(ansi.Hardwrap(message, max(width, 1), true), "\n")...) + } + if s.preview.ApprovalDecisions != "" { + lines = append(lines, strings.Split(ansi.Hardwrap(i18n.Text("OFFERED DECISIONS")+" // "+s.preview.ApprovalDecisions, max(width, 1), true), "\n")...) } return append(lines, strings.Split(ansi.Hardwrap(codex.SanitizeSessionContext(s.preview.Text), max(width, 1), true), "\n")...) } @@ -134,32 +143,57 @@ func (m Model) renderMonitorContextDetail(width, height int, colors palette) str rows := max(height-2, 1) controls := "" if m.monitorApprovalControls(width, height) { - a, b := m.monitorApprovalLabels() - controls = m.renderContextAction("approve", a, colors) + strings.Repeat(" ", monitorApprovalSlotWidth()-lipgloss.Width(a)+2) + m.renderContextAction("decline", b, colors) + controls = m.renderMonitorApprovalControls(width, height, colors) + } else if m.monitorPromptRows(width, height) > 0 { + controls = m.renderMonitorPrompt(width, colors) } else if m.monitorApprovalNotice != "" { controls = colors.label().Render(ansi.Truncate(m.monitorApprovalNotice, max(width-4, 1), "")) } - textRows := rows + controlRows := 0 if controls != "" { - textRows-- + controlRows = strings.Count(controls, "\n") + 1 } + textRows, gap, _ := monitorContextBodyLayout(height, controlRows) start := min(max(m.monitorContextScroll, 0), max(len(lines)-textRows, 0)) end := min(start+textRows, len(lines)) for i := start; i < end; i++ { lines[i] = colors.label().Render(lines[i]) } - body := strings.Join(lines[start:end], "\n") + bodyLines := append([]string(nil), lines[start:end]...) if controls != "" { - body = controls + "\n" + body + for len(bodyLines) < textRows+gap { + bodyLines = append(bodyLines, "") + } + bodyLines = append(bodyLines, controls) } - return frameSizedWithTitleAction(width, rows, i18n.Text("SESSION CONTEXT"), m.renderContextAction("close", monitorDismissLabel, colors), body, colors.primary, colors) + body := strings.Join(bodyLines, "\n") + action := m.renderContextAction("close", monitorDismissLabel, colors) + if width >= lipgloss.Width(monitorContextInfo+" "+monitorDismissLabel)+8 { + action = m.renderContextAction("cycle", monitorContextInfo, colors) + " " + action + } + return frameSizedWithTitleAction(width, rows, i18n.Text("SESSION CONTEXT"), action, body, colors.primary, colors) +} + +// Reserve the footer before allocating the scroll viewport. Short terminals +// drop the spacer first, preserving at least three text rows for buttons. +func monitorContextBodyLayout(height, controls int) (textRows, gap, controlY int) { + bodyRows := max(height-2, 1) + textRows = max(bodyRows-controls, 0) + if controls > 0 && textRows >= 4 { + gap = 1 + textRows-- + } + controlY = 1 + textRows + gap // one title row, relative to the detail frame + return } func (m *Model) toggleMonitorContext() { + m.monitorPrompt = monitorPromptState{} m.monitorApprovalConfirm = "" m.monitorApprovalNotice = "" m.monitorContextHidden = !m.monitorContextHidden m.monitorContextDetail = "" + m.monitorContextExpanded = "" m.monitorContextHover = "" m.monitorContextScroll = 0 m.persistPreferences() @@ -170,10 +204,12 @@ func (m *Model) openMonitorContext(id string) { return } for _, s := range m.monitorSessionData { - if s.id == id && s.preview.Text != "" && m.monitorSessionVisible(s) { + if s.id == id && (s.preview.Text != "" || id == m.monitorContextExpanded) && m.monitorSessionVisible(s) { + m.monitorPrompt = monitorPromptState{} m.monitorApprovalConfirm = "" m.monitorApprovalNotice = "" m.monitorContextDetail = id + m.monitorContextExpanded = "" m.monitorContextScroll = 0 return } @@ -182,10 +218,14 @@ func (m *Model) openMonitorContext(id string) { func (m *Model) scrollMonitorContext(delta int) { g := m.dashboardLayout() - rows := max(g.meterHeight-2, 1) - if m.monitorApprovalControls(g.contentWidth, g.meterHeight) || m.monitorApprovalNotice != "" { - rows-- + n := m.monitorApprovalControlRows(g.contentWidth, g.meterHeight) + if n == 0 { + n = m.monitorPromptRows(g.contentWidth, g.meterHeight) + } + if n == 0 && m.monitorApprovalNotice != "" { + n = 1 } + rows, _, _ := monitorContextBodyLayout(g.meterHeight, n) limit := max(len(m.contextDetailLines(max(g.contentWidth-4, 1)))-rows, 0) m.monitorContextScroll = min(max(m.monitorContextScroll+delta, 0), limit) } @@ -197,8 +237,19 @@ func (m Model) updateMonitorContextKey(key string) (Model, tea.Cmd, bool) { } if m.monitorContextDetail != "" && !m.monitorContextHidden { switch key { - case "esc", "x", "enter", "i": - m.monitorContextDetail = "" + case "enter": + g := m.dashboardLayout() + if m.monitorPromptRows(g.contentWidth, g.meterHeight) > 0 && m.monitorPromptOffer().Token != "" { + cmd := m.focusMonitorPrompt() + return m, cmd, true + } + m.cycleMonitorContext("") + return m, nil, true + case "esc", "x": + m.stepBackMonitorContext() + return m, nil, true + case "i": + m.cycleMonitorContext("") return m, nil, true case "up": m.scrollMonitorContext(-1) @@ -210,23 +261,19 @@ func (m Model) updateMonitorContextKey(key string) (Model, tea.Cmd, bool) { m.scrollMonitorContext(max(m.dashboardLayout().meterHeight-4, 1)) case "q", "ctrl+c", "tab", "shift+tab": m.monitorContextDetail = "" + m.monitorContextExpanded = "" return m, nil, false default: return m, nil, true } return m, nil, true } + if key == "esc" && m.monitorContextExpanded != "" { + m.stepBackMonitorContext() + return m, nil, true + } if key == "i" || key == "enter" { - id := m.monitorSelectedID - if id == "" { - for _, s := range m.monitorSessionData { - if m.monitorSessionVisible(s) { - id = s.id - break - } - } - } - m.openMonitorContext(id) + m.cycleMonitorContext("") return m, nil, true } return m, nil, false @@ -240,13 +287,28 @@ func (m Model) monitorContextAt(x, y int) string { x -= 2 y -= g.meterY if m.monitorContextDetail != "" && !m.monitorContextHidden { - if y == 1 && m.monitorApprovalControls(g.contentWidth, g.meterHeight) { - a, b := m.monitorApprovalLabels() - if x >= 2 && x < 2+lipgloss.Width(a) { - return "approve" + if rows := m.monitorPromptRows(g.contentWidth, g.meterHeight); rows > 0 && m.monitorPromptOffer().Token != "" { + _, _, controlY := monitorContextBodyLayout(g.meterHeight, rows) + if y == controlY+1 && x >= 2 && x < g.contentWidth-2 { + return "prompt" } - if x >= 4+monitorApprovalSlotWidth() && x < 4+monitorApprovalSlotWidth()+lipgloss.Width(b) { - return "decline" + } + label := monitorContextInfo + " " + monitorDismissLabel + if r, ok := contextActionRect(g.contentWidth, 0, label); ok { + r.width = lipgloss.Width(monitorContextInfo) + if r.contains(x, y) { + return "cycle" + } + } + buttons := m.monitorApprovalButtons(g.contentWidth, g.meterHeight) + controlRows := 0 + if len(buttons) > 0 { + controlRows = buttons[len(buttons)-1].y + 1 + } + _, _, controlY := monitorContextBodyLayout(g.meterHeight, controlRows) + for _, b := range buttons { + if y == controlY+b.y && x >= b.x+2 && x < b.x+2+lipgloss.Width(b.label) { + return b.action } } if r, ok := contextActionRect(g.contentWidth, 0, monitorDismissLabel); ok && r.contains(x, y) { @@ -263,14 +325,21 @@ func (m Model) monitorContextAt(x, y int) string { if m.monitorContextHidden { return "" } + if hit := m.expandedContextAt(x, y); hit != "" { + return hit + } sessions, heights, _ := m.monitorSessionPage(a.graphHeight) rowY := a.topHeight + a.gap - 1 for i, s := range sessions { + if s.id == m.monitorContextExpanded { + rowY += heights[i] + continue + } mw, cw, gw := contextColumns(a.width, s) if cw == 0 { cw = gw } - if s.preview.Text != "" { + if m.monitorContextActionVisible(s) { if r, ok := contextActionRect(cw, rowY, monitorContextInfo); ok { r.x += mw + 1 if r.contains(x, y) { @@ -288,15 +357,21 @@ func (m Model) updateMonitorContextMouse(msg tea.MouseMsg) (Model, tea.Cmd, bool _, click := msg.(tea.MouseClickMsg) m.monitorContextHover = m.monitorContextAt(mouse.X, mouse.Y) if click && mouse.Button == tea.MouseLeft && m.monitorContextHover != "" { - switch m.monitorContextHover { - case "approve", "decline": + if strings.HasPrefix(m.monitorContextHover, "decision:") { return m.monitorApprovalAction(m.monitorContextHover) + } + switch m.monitorContextHover { + case "prompt": + cmd := m.focusMonitorPrompt() + return m, cmd, true + case "cycle": + m.cycleMonitorContext("") case "privacy": m.toggleMonitorContext() case "close": - m.monitorContextDetail = "" + m.stepBackMonitorContext() default: - m.openMonitorContext(m.monitorContextHover) + m.cycleMonitorContext(m.monitorContextHover) } return m, nil, true } diff --git a/internal/ui/monitor_context_modes.go b/internal/ui/monitor_context_modes.go new file mode 100644 index 0000000..575f11e --- /dev/null +++ b/internal/ui/monitor_context_modes.go @@ -0,0 +1,205 @@ +package ui + +import ( + "strings" + "time" + + "charm.land/lipgloss/v2" + "github.com/charmbracelet/x/ansi" + "github.com/merefield/codexometer/internal/codex" + "github.com/merefield/codexometer/internal/i18n" +) + +func (m Model) monitorContextTarget() string { + if m.monitorContextDetail != "" { + return m.monitorContextDetail + } + return m.monitorContextExpanded +} + +func (m Model) initialMonitorContextTarget() string { + var first, newest string + var at time.Time + for _, s := range m.monitorSessionData { + if !m.monitorSessionVisible(s) { + continue + } + if s.id == m.monitorSelectedID { + return s.id + } + if s.preview.Text == "" { + continue + } + if first == "" { + first = s.id + } + if s.attention == codex.SessionAttentionApproval && (newest == "" || s.preview.At.After(at)) { + newest = s.id + at = s.preview.At + } + } + if newest != "" { + return newest + } + return first +} + +func (m *Model) cycleMonitorContext(id string) { + m.monitorPrompt = monitorPromptState{} + if m.monitorContextHidden { + return + } + if id == "" { + id = m.monitorContextTarget() + if m.monitorContextDetail == "" && m.monitorSelectedID != "" { + id = m.initialMonitorContextTarget() + } + if id == "" { + id = m.initialMonitorContextTarget() + } + } + if id == "" { + return + } + m.monitorApprovalConfirm = "" + m.monitorApprovalNotice = "" + m.monitorContextScroll = 0 + if m.monitorContextDetail == id { + m.monitorContextDetail = "" + m.monitorContextExpanded = "" + return + } + if m.monitorContextExpanded == id { + m.openMonitorContext(id) + return + } + for _, s := range m.monitorSessionData { + if s.id == id && m.monitorSessionVisible(s) { + m.monitorContextDetail = "" + m.monitorContextExpanded = id + return + } + } +} + +// With an explicit selection, expose the keyboard action only on that row. +// An empty preview is still a valid destination (and may offer a live prompt). +func (m Model) monitorContextActionVisible(s monitorSession) bool { + return !m.monitorContextHidden && (m.monitorSelectedID == s.id || + m.monitorSelectedID == "" && s.preview.Text != "") +} + +func (m *Model) stepBackMonitorContext() { + m.monitorPrompt = monitorPromptState{} + m.monitorApprovalConfirm = "" + m.monitorContextScroll = 0 + if m.monitorContextDetail != "" { + m.monitorContextExpanded = m.monitorContextDetail + m.monitorContextDetail = "" + } else { + m.monitorContextExpanded = "" + } +} + +func expandedContextLines(width int, s monitorSession) []string { + if s.preview.Text == "" { + return []string{i18n.Text("NO CONTEXT")} + } + header := contextTitle(s.preview) + " // " + s.preview.ThreadID + " // " + s.preview.Source + text := header + "\n" + codex.SanitizeSessionContext(s.preview.Text) + return strings.Split(ansi.Hardwrap(text, max(width-4, 1), true), "\n") +} + +// Inline decisions are only offered when the complete source/request fits +// above them. Long commands and rules must be reviewed in the full detail. +func (m Model) expandedApprovalButtons(width, height int, s monitorSession) []monitorApprovalButton { + buttons := m.monitorApprovalButtons(width, height) + if len(buttons) == 0 { + return nil + } + n := buttons[len(buttons)-1].y + 1 + rows, _, _ := monitorContextBodyLayout(height, n) + if len(expandedContextLines(width, s)) > rows { + return nil + } + return buttons +} + +func (m Model) expandedContextAction(width, height int, s monitorSession) string { + if len(m.expandedApprovalButtons(width, height, s)) == 0 && s.preview.Kind == codex.SessionContextApproval { + label := monitorContextInfo + " " + i18n.Text("OPEN DETAIL") + if width >= lipgloss.Width(label)+8 { + return label + } + } + return monitorContextInfo +} + +func (m Model) renderExpandedContext(width, height int, s monitorSession, colors palette) string { + lines := expandedContextLines(width, s) + buttons := m.expandedApprovalButtons(width, height, s) + n := 0 + controls := "" + if len(buttons) > 0 { + n = buttons[len(buttons)-1].y + 1 + controls = m.renderMonitorApprovalControls(width, height, colors) + } else if m.monitorApprovalNotice != "" { + n = 1 + controls = colors.label().Render(ansi.Truncate(m.monitorApprovalNotice, max(width-4, 1), "")) + } + textRows, gap, _ := monitorContextBodyLayout(height, n) + if len(lines) > textRows { + lines = lines[:textRows] + if textRows > 0 { + lines[textRows-1] = ansi.Truncate(lines[textRows-1], max(width-5, 0), "") + "…" + } + } + for i := range lines { + lines[i] = colors.label().Render(lines[i]) + } + if n > 0 { + for len(lines) < textRows+gap { + lines = append(lines, "") + } + lines = append(lines, controls) + } + return frameSizedWithTitleAction(width, max(height-2, 1), contextTitle(s.preview), m.renderContextAction(s.id, m.expandedContextAction(width, height, s), colors), strings.Join(lines, "\n"), colors.primary, colors) +} + +func (m Model) expandedContextAt(x, y int) string { + if m.monitorContextExpanded == "" || m.monitorContextHidden { + return "" + } + g := m.dashboardLayout() + a := layoutMonitorArea(g.contentWidth, g.meterHeight) + sessions, heights, _ := m.monitorSessionPage(a.graphHeight) + rowY := a.topHeight + a.gap - 1 + for i, s := range sessions { + if s.id == m.monitorContextExpanded { + mw, cw, _ := monitorSessionColumnWidths(a.width) + x -= mw + 1 + y -= rowY + if r, ok := contextActionRect(cw, 0, m.expandedContextAction(cw, heights[i], s)); ok && r.contains(x, y) { + return s.id + } + // If a long action label cannot fit, retain a minimal clickable [i]. + if cw < lipgloss.Width(m.expandedContextAction(cw, heights[i], s))+8 { + if r, ok := contextActionRect(cw, 0, monitorContextInfo); ok && r.contains(x, y) { + return s.id + } + } + buttons := m.expandedApprovalButtons(cw, heights[i], s) + if len(buttons) > 0 { + _, _, cy := monitorContextBodyLayout(heights[i], buttons[len(buttons)-1].y+1) + for _, b := range buttons { + if y == cy+b.y && x >= 2+b.x && x < 2+b.x+lipgloss.Width(b.label) { + return b.action + } + } + } + return "" + } + rowY += heights[i] + } + return "" +} diff --git a/internal/ui/monitor_context_modes_test.go b/internal/ui/monitor_context_modes_test.go new file mode 100644 index 0000000..7aa91f3 --- /dev/null +++ b/internal/ui/monitor_context_modes_test.go @@ -0,0 +1,218 @@ +package ui + +import ( + "strings" + "testing" + "time" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/charmbracelet/x/ansi" + "github.com/merefield/codexometer/internal/codex" + "github.com/merefield/codexometer/internal/i18n" +) + +func TestMonitorContextThreeStageCycleAndTarget(t *testing.T) { + m := contextTestModel() + m.monitorSessionData[1].attention = codex.SessionAttentionApproval + m.monitorSessionData[2].attention = codex.SessionAttentionApproval + m.monitorSessionData[2].preview.At = time.Now().Add(time.Minute) + step := func(k tea.KeyPressMsg) { updated, _ := m.Update(k); m = updated.(Model) } + step(key('i')) + if m.monitorContextExpanded != "root-three" || m.monitorContextDetail != "" { + t.Fatal("did not choose newest approval") + } + m.monitorSessionData[1].preview.At = time.Now().Add(time.Hour) + step(key('i')) + if m.monitorContextDetail != "root-three" || m.monitorContextExpanded != "" { + t.Fatal("target changed on new approval") + } + step(tea.KeyPressMsg{Code: tea.KeyEscape}) + if m.monitorContextExpanded != "root-three" { + t.Fatal("escape did not step back to expanded") + } + step(tea.KeyPressMsg{Code: tea.KeyEscape}) + if m.monitorContextTarget() != "" { + t.Fatal("escape did not collapse") + } + m.monitorSelectedID = "root-one" + m.monitorSessionData[0].preview = codex.SessionContext{} + step(key('i')) + step(key('i')) + if m.monitorContextDetail != "root-one" { + t.Fatal("explicit selection ignored because text was missing") + } + step(key('i')) + if m.monitorContextTarget() != "" { + t.Fatal("detail did not cycle to compact") + } +} + +func TestMonitorContextSelectionOverridesExpandedRow(t *testing.T) { + m := contextTestModel() + m.monitorSessionData[1].preview = codex.SessionContext{} + m.cycleMonitorContext("root-one") + m.monitorSelectedID = "root-one" + m.selectMonitorSession(1) + if m.monitorSelectedID != "root-two" || m.monitorContextExpanded != "" { + t.Fatal("selection left an unrelated expanded target", m.monitorSelectedID, m.monitorContextExpanded) + } + for _, size := range [][2]int{{40, 16}, {80, 24}, {120, 40}, {180, 50}} { + m.width, m.height = size[0], size[1] + m.selectMonitorSession(0) + out := ansi.Strip(m.render()) + if got := strings.Count(out, monitorContextInfo); got != 1 { + t.Fatalf("%v: got %d info actions\n%s", size, got, out) + } + for y, line := range strings.Split(out, "\n") { + if pos := strings.Index(line, monitorContextInfo); pos >= 0 { + x := lipgloss.Width(line[:pos]) + for dx := 0; dx < 3; dx++ { + if got := m.monitorContextAt(x+dx, y); got != "root-two" { + t.Fatalf("selected empty row click target=%q", got) + } + } + } + } + } + // Also defend against a pre-existing expansion restored alongside selection. + m.monitorContextExpanded = "root-one" + updated, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + m = updated.(Model) + if m.monitorContextExpanded != "root-two" { + t.Fatal("Enter followed old expansion") + } + updated, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + m = updated.(Model) + if m.monitorContextDetail != "root-two" { + t.Fatal("empty selected row could not open detail") + } +} + +func TestExpandedContextResponsiveHitTargets(t *testing.T) { + for _, size := range []struct{ w, h, n int }{{40, 16, 1}, {80, 24, 1}, {120, 40, 1}, {180, 45, 1}, {120, 30, 3}, {180, 40, 8}} { + m := approvalTestModel() + m.width = size.w + m.height = size.h + m.monitorContextDetail = "" + m.monitorContextExpanded = "" + m.monitorSessionData[0].preview.ThreadID = m.monitorSessionData[0].id + for len(m.monitorSessionData) < size.n { + s := m.monitorSessionData[1] + s.id += "x" + strings.Repeat("x", len(m.monitorSessionData)) + m.monitorSessionData = append(m.monitorSessionData, s) + } + m.monitorSessionData = m.monitorSessionData[:size.n] + if strings.Contains(ansi.Strip(m.render()), approvalOptionLabel("accept", false)) { + t.Fatal("compact exposed approval") + } + m.cycleMonitorContext(m.monitorSessionData[0].id) + for _, long := range []bool{false, true} { + if long { + m.monitorSessionData[0].preview.Text = strings.Repeat("long request\n", 200) + } + out := m.render() + if lipgloss.Width(out) > size.w || lipgloss.Height(out) > size.h { + t.Fatalf("expanded overflow %dx%d", size.w, size.h) + } + g := m.dashboardLayout() + a := layoutMonitorArea(g.contentWidth, g.meterHeight) + sessions, heights, _ := m.monitorSessionPage(a.graphHeight) + _, cw, _ := monitorSessionColumnWidths(a.width) + var buttons []monitorApprovalButton + for i, s := range sessions { + if s.id == m.monitorContextExpanded { + buttons = m.expandedApprovalButtons(cw, heights[i], s) + } + } + if long && len(buttons) != 0 { + t.Fatal("truncated inline request exposed decisions") + } + found := 0 + for y, line := range strings.Split(ansi.Strip(out), "\n") { + for _, b := range buttons { + pos := strings.Index(line, b.label) + if pos < 0 { + continue + } + found++ + x := lipgloss.Width(line[:pos]) + for dx := 0; dx < lipgloss.Width(b.label); dx++ { + if got := m.monitorContextAt(x+dx, y); got != b.action { + t.Fatalf("inline decision miss %dx%d %d,%d %q", size.w, size.h, x+dx, y, got) + } + } + updated, cmd := m.Update(tea.MouseClickMsg(tea.Mouse{X: x, Y: y, Button: tea.MouseLeft})) + if b.action == "decision:0" && (cmd != nil || updated.(Model).monitorApprovalConfirm == "") { + t.Fatal("inline grant skipped confirmation") + } + } + } + if found != len(buttons) { + t.Fatal("rendered decision count mismatch") + } + if len(buttons) == 0 && cw >= lipgloss.Width(monitorContextInfo+" "+i18n.Text("OPEN DETAIL"))+8 && !strings.Contains(ansi.Strip(out), i18n.Text("OPEN DETAIL")) { + t.Fatal("missing detail fallback") + } + } + } +} + +func TestMonitorExpandedSelectionDismissalAndConfirmation(t *testing.T) { + m := approvalTestModel() + m.monitorContextDetail = "" + m.monitorContextExpanded = "" + m.cycleMonitorContext("root-one") + m, _, _ = m.monitorApprovalAction("decision:0") + if m.monitorApprovalConfirm == "" { + t.Fatal("confirmation missing") + } + m.cycleMonitorContext("") + if m.monitorApprovalConfirm != "" { + t.Fatal("confirmation survived mode transition") + } + m.stepBackMonitorContext() + m.dismissMonitorSession("root-one") + if m.monitorContextTarget() != "" { + t.Fatal("dismissed row retained expansion") + } +} + +func TestMonitorContextMouseCyclesAllThreeStages(t *testing.T) { + m := contextTestModel() + for stage := 0; stage < 3; stage++ { + wanted := "root-one" + if stage == 2 { + wanted = "cycle" + } + clicked := false + for y, line := range strings.Split(ansi.Strip(m.render()), "\n") { + if clicked { + break + } + pos := strings.Index(line, monitorContextInfo) + if pos < 0 { + continue + } + x := lipgloss.Width(line[:pos]) + if m.monitorContextAt(x, y) != wanted { + continue + } + next, _ := m.Update(tea.MouseClickMsg(tea.Mouse{X: x, Y: y, Button: tea.MouseLeft})) + m = next.(Model) + clicked = true + } + if !clicked { + t.Fatalf("stage %d info action missing", stage) + } + if stage == 0 && m.monitorContextExpanded != "root-one" { + t.Fatal("mouse did not expand") + } + if stage == 1 && m.monitorContextDetail != "root-one" { + t.Fatal("mouse did not open detail") + } + if stage == 2 && m.monitorContextTarget() != "" { + t.Fatal("mouse did not collapse") + } + } +} diff --git a/internal/ui/monitor_context_test.go b/internal/ui/monitor_context_test.go index f5a0587..869eebc 100644 --- a/internal/ui/monitor_context_test.go +++ b/internal/ui/monitor_context_test.go @@ -10,6 +10,7 @@ import ( "charm.land/lipgloss/v2" "github.com/charmbracelet/x/ansi" "github.com/merefield/codexometer/internal/codex" + "github.com/merefield/codexometer/internal/i18n" ) func contextTestModel() Model { @@ -20,6 +21,22 @@ func contextTestModel() Model { return m } +func TestMonitorCompletionIsIndependentOfPreview(t *testing.T) { + m := contextTestModel() + m.monitorSessionData[0].attention = codex.SessionAttentionComplete + m.monitorSessionData[0].preview = codex.SessionContext{} + for _, hidden := range []bool{false, true} { + m.monitorContextHidden = hidden + if out := ansi.Strip(m.render()); !strings.Contains(out, i18n.Text("TURN COMPLETE")) { + t.Fatal("completion badge missing without visible reply") + } + } + s := monitorSession{attention: codex.SessionAttentionInput, preview: codex.SessionContext{Kind: codex.SessionContextReply}} + if got := monitorSessionAttentionLabel(s); got != i18n.Text("INPUT NEEDED") { + t.Fatal("old reply hid actual input request", got) + } +} + func TestMonitorContextResponsiveHitTargets(t *testing.T) { for _, size := range []struct{ w, h int }{{40, 16}, {60, 24}, {80, 24}, {100, 30}, {120, 40}, {180, 50}} { m := contextTestModel() @@ -50,9 +67,10 @@ func TestMonitorContextResponsiveHitTargets(t *testing.T) { } updated, _ := m.Update(tea.MouseClickMsg(tea.Mouse{X: x, Y: y, Button: tea.MouseLeft})) detail := updated.(Model) - if detail.monitorContextDetail == "" { + if detail.monitorContextExpanded == "" { t.Fatal("click failed") } + detail.cycleMonitorContext("") modal := detail.render() if lipgloss.Width(modal) > size.w || lipgloss.Height(modal) > size.h { t.Fatal("detail overflow") diff --git a/internal/ui/monitor_prompt.go b/internal/ui/monitor_prompt.go new file mode 100644 index 0000000..46e3814 --- /dev/null +++ b/internal/ui/monitor_prompt.go @@ -0,0 +1,248 @@ +package ui + +import ( + "context" + "fmt" + "strings" + "time" + + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/x/ansi" + "github.com/merefield/codexometer/internal/codex" + "github.com/merefield/codexometer/internal/i18n" +) + +type monitorPromptState struct { + session string + offer codex.SessionPromptOffer + input textinput.Model + answers [3]string + question int + choice int + busy bool + notice string +} + +type monitorPromptResult struct { + session, token string + err error +} + +func (m Model) monitorPromptOffer() codex.SessionPromptOffer { + if m.meterView != viewMonitor || m.monitorContextDetail == "" || m.monitorContextHidden { + return codex.SessionPromptOffer{} + } + c, ok := m.fetcher.(codex.SessionPromptClient) + if !ok { + return codex.SessionPromptOffer{} + } + s, ok := m.contextDetailSession() + if !ok { + return codex.SessionPromptOffer{} + } + if s.preview.Kind == codex.SessionContextApproval { + return codex.SessionPromptOffer{} + } + thread := s.id + // A question belongs to its exact child/root source. Ordinary follow-ups + // belong to the displayed root CLI session, not a child's last reply. + if s.preview.Kind == codex.SessionContextQuestion && s.preview.ThreadID != "" { + thread = s.preview.ThreadID + } + o := c.SessionPrompt(thread) + if len(o.Questions) > 0 && (s.preview.Kind != codex.SessionContextQuestion || s.preview.InputToken != o.Token) { + return codex.SessionPromptOffer{} + } + if s.preview.Kind == codex.SessionContextQuestion && len(o.Questions) == 0 { + return codex.SessionPromptOffer{} + } + return o +} + +func (m Model) monitorPromptRows(width, height int) int { + if width < 24 || height < 8 || m.monitorContextDetail == "" || m.monitorContextHidden { + return 0 + } + if s, ok := m.contextDetailSession(); ok && s.preview.Kind == codex.SessionContextApproval { + return 0 + } + if m.monitorPromptOffer().Token != "" || m.monitorPrompt.session == m.monitorContextDetail && (m.monitorPrompt.busy || m.monitorPrompt.notice != "") { + return 3 + } + return 0 +} + +func (m *Model) focusMonitorPrompt() tea.Cmd { + o := m.monitorPromptOffer() + if o.Token == "" || m.monitorPrompt.busy { + return nil + } + if m.monitorPrompt.offer.Token != o.Token { + m.monitorPrompt = monitorPromptState{session: m.monitorContextDetail, offer: o, input: textinput.New(), choice: -1} + m.monitorPrompt.input.CharLimit = 4096 + } + m.monitorPrompt.notice = "" + return m.monitorPrompt.input.Focus() +} + +func (m Model) renderMonitorPrompt(width int, colors palette) string { + p := m.monitorPrompt + o := m.monitorPromptOffer() + header := i18n.Text("FOLLOW-UP") + " // " + o.ThreadID + if len(o.Questions) > 0 { + n := 0 + if p.offer.Token == o.Token { + n = p.question + } + n = min(n, len(o.Questions)-1) + header = fmt.Sprintf("%s %d/%d // %s", i18n.Text("REPLY"), n+1, len(o.Questions), o.Questions[n].Text) + } + line := i18n.Text("[ Click here or press Enter to write ]") + hint := i18n.Text("Enter: send / next answer • Esc: leave editor • ↑/↓: choices") + if p.offer.Token == o.Token && p.input.Focused() && !p.busy { + input := p.input + input.SetWidth(max(width-6, 1)) + input.EchoMode = textinput.EchoNormal + if len(o.Questions) > 0 && o.Questions[p.question].Secret { + input.EchoMode = textinput.EchoPassword + } + styles := input.Styles() + styles.Focused.Text = colors.label() + styles.Focused.Prompt = colors.label().Foreground(colors.primary) + styles.Focused.Placeholder = colors.label() + styles.Cursor.Color = colors.primary + input.SetStyles(styles) + line = input.View() + } + if p.busy { + line = i18n.Text("Sending…") + } + if p.notice != "" { + hint = p.notice + } + return colors.label().Render(ansi.Truncate(header, max(width-4, 1), "…")) + "\n" + ansi.Truncate(line, max(width-4, 1), "") + "\n" + colors.label().Render(ansi.Truncate(hint, max(width-4, 1), "…")) +} + +func (m Model) updateMonitorPrompt(msg tea.Msg) (Model, tea.Cmd, bool) { + p := &m.monitorPrompt + if result, ok := msg.(monitorPromptResult); ok { + if p.session == result.session && p.offer.Token == result.token { + p.busy = false + p.input.Reset() + p.input.Blur() + p.answers = [3]string{} + p.notice = i18n.Text("Text sent; check Codex for the outcome.") + if result.err != nil { + p.notice = i18n.Text("Send unconfirmed; check Codex before retrying.") + } + p.offer = codex.SessionPromptOffer{} + } + return m, nil, true + } + wasFocused := p.input.Focused() + g := m.dashboardLayout() + if wasFocused && m.monitorPromptRows(g.contentWidth, g.meterHeight) == 0 { + p.input.Blur() + } + if p.session != "" && (m.meterView != viewMonitor || m.monitorContextDetail != p.session || m.monitorContextHidden) { + *p = monitorPromptState{} + } else if p.offer.Token != "" && !p.busy && m.monitorPromptOffer().Token != p.offer.Token { + *p = monitorPromptState{session: p.session, notice: i18n.Text("Prompt changed; review the session before replying.")} + } + if wasFocused && !p.input.Focused() { + switch msg.(type) { + case tea.KeyPressMsg, tea.PasteMsg: + return m, nil, true + } + } + if !p.input.Focused() || p.busy { + return m, nil, false + } + if key, ok := msg.(tea.KeyPressMsg); ok { + switch key.String() { + case "esc", "ctrl+c": + p.input.Blur() + return m, nil, true + case "enter": + return m.submitMonitorPrompt() + case "up", "down": + if len(p.offer.Questions) > 0 { + options := p.offer.Questions[p.question].Options + if len(options) > 0 { + if key.String() == "down" { + p.choice = (p.choice + 1) % len(options) + } else { + p.choice = (p.choice - 1 + len(options)) % len(options) + } + p.input.SetValue(options[p.choice]) + p.input.CursorEnd() + } + } + return m, nil, true + } + } + // Only editor messages are consumed. Telemetry and mouse updates must still + // reach the dashboard while composing. Paste is never interpreted as Enter. + switch msg.(type) { + case tea.KeyPressMsg, tea.PasteMsg: + var cmd tea.Cmd + p.input, cmd = p.input.Update(msg) + return m, cmd, true + } + before := p.input.Value() + var cmd tea.Cmd + p.input, cmd = p.input.Update(msg) + if cmd != nil || before != p.input.Value() { + return m, cmd, true + } + return m, nil, false +} + +func (m Model) submitMonitorPrompt() (Model, tea.Cmd, bool) { + p := &m.monitorPrompt + if p.busy || p.offer.Token == "" || m.monitorPromptOffer().Token != p.offer.Token { + return m, nil, true + } + value := strings.TrimSpace(p.input.Value()) + if value == "" { + return m, nil, true + } + if len(p.offer.Questions) > 0 { + q := p.offer.Questions[p.question] + if !q.FreeText { + found := false + for _, o := range q.Options { + if value == o { + found = true + } + } + if !found { + p.notice = i18n.Text("Choose an offered answer with ↑/↓.") + return m, nil, true + } + } + } + p.answers[p.question] = value + if p.question+1 < len(p.offer.Questions) { + p.question++ + p.choice = -1 + p.input.Reset() + p.notice = "" + return m, nil, true + } + client, ok := m.fetcher.(codex.SessionPromptClient) + if !ok { + return m, nil, true + } + answers := append([]string(nil), p.answers[:p.question+1]...) + token, session := p.offer.Token, p.session + p.busy = true + p.input.Blur() + p.notice = "" + return m, func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), 6*time.Second) + defer cancel() + return monitorPromptResult{session: session, token: token, err: client.SendSessionPrompt(ctx, token, answers)} + }, true +} diff --git a/internal/ui/monitor_prompt_test.go b/internal/ui/monitor_prompt_test.go new file mode 100644 index 0000000..52ed88f --- /dev/null +++ b/internal/ui/monitor_prompt_test.go @@ -0,0 +1,191 @@ +package ui + +import ( + "context" + "errors" + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/charmbracelet/x/ansi" + "github.com/merefield/codexometer/internal/codex" + "github.com/merefield/codexometer/internal/i18n" +) + +type promptTestClient struct { + offer codex.SessionPromptOffer + answers []string + err error +} + +func (c *promptTestClient) Fetch(context.Context) (codex.Snapshot, error) { + return codex.DemoSnapshot(), nil +} +func (c *promptTestClient) SessionPrompt(thread string) codex.SessionPromptOffer { + if thread == c.offer.ThreadID { + return c.offer + } + return codex.SessionPromptOffer{} +} +func (c *promptTestClient) SendSessionPrompt(_ context.Context, token string, answers []string) error { + if token != c.offer.Token { + return errors.New("expired") + } + c.answers = answers + c.offer.Token = "" + return c.err +} +func promptTestModel() (Model, *promptTestClient) { + m := contextTestModel() + c := &promptTestClient{offer: codex.SessionPromptOffer{Token: "prompt", ThreadID: "root-one"}} + m.fetcher = c + m.openMonitorContext("root-one") + return m, c +} +func promptKey(m Model, code rune, text string) (Model, tea.Cmd) { + n, cmd := m.Update(tea.KeyPressMsg(tea.Key{Code: code, Text: text})) + return n.(Model), cmd +} + +func TestMonitorPromptEnterAndHotkeyIsolation(t *testing.T) { + m, c := promptTestModel() + m, _ = promptKey(m, tea.KeyEnter, "") + if !m.monitorPrompt.input.Focused() { + t.Fatal("Enter did not focus") + } + for _, text := range []string{"Q", "s", "t", "h", "i", "X", "Hello 世界"} { + m, _ = promptKey(m, []rune(text)[0], text) + } + if m.monitorContextHidden || m.monitorContextDetail != "root-one" { + t.Fatal("typing triggered dashboard action") + } + want := "QsthiXHello 世界" + if m.monitorPrompt.input.Value() != want { + t.Fatalf("case/input lost: %q", m.monitorPrompt.input.Value()) + } + m, cmd := promptKey(m, tea.KeyEnter, "") + if cmd == nil || !m.monitorPrompt.busy || c.answers != nil { + t.Fatal("send not queued exclusively") + } + n, _ := m.Update(cmd()) + m = n.(Model) + if len(c.answers) != 1 || c.answers[0] != want || m.monitorPrompt.busy { + t.Fatal("wrong submission", c.answers) + } + if m.monitorPrompt.input.Value() != "" { + t.Fatal("submitted draft retained") + } +} + +func TestMonitorPromptQuestionsAndSecret(t *testing.T) { + m, c := promptTestModel() + c.offer.Questions = []codex.PromptQuestion{{ID: "a", Text: "Pick one", Options: []string{"First", "Second"}}, {ID: "b", Text: "Why?", FreeText: true, Secret: true}} + m.monitorSessionData[0].preview.Kind = codex.SessionContextQuestion + m.monitorSessionData[0].preview.InputToken = c.offer.Token + m.focusMonitorPrompt() + m.monitorPrompt.input.SetValue("not offered") + m, cmd, _ := m.submitMonitorPrompt() + if cmd != nil || m.monitorPrompt.question != 0 { + t.Fatal("invalid choice sent") + } + m, _ = promptKey(m, tea.KeyDown, "") + m, cmd = promptKey(m, tea.KeyEnter, "") + if cmd != nil || m.monitorPrompt.question != 1 || c.answers != nil { + t.Fatal("partial answers sent") + } + m.monitorPrompt.input.SetValue("supersecret") + if strings.Contains(ansi.Strip(m.render()), "supersecret") { + t.Fatal("secret displayed") + } + m, cmd = promptKey(m, tea.KeyEnter, "") + if cmd == nil { + t.Fatal("missing complete reply") + } + cmd() + if strings.Join(c.answers, "/") != "First/supersecret" { + t.Fatal(c.answers) + } +} + +func TestMonitorPromptStaleFocusPasteAndEsc(t *testing.T) { + m, c := promptTestModel() + m.focusMonitorPrompt() + n, cmd := m.Update(tea.PasteMsg{Content: "Hello\nWORLD"}) + m = n.(Model) + if m.monitorPrompt.busy || c.answers != nil || !strings.Contains(m.monitorPrompt.input.Value(), "WORLD") { + t.Fatal("paste sent or lost") + } + m, _ = promptKey(m, tea.KeyEscape, "") + if m.monitorPrompt.input.Focused() || m.monitorContextDetail == "" { + t.Fatal("Esc should only blur first") + } + m.focusMonitorPrompt() + c.offer.Token = "new-request" + m, cmd = promptKey(m, 'q', "q") + if cmd != nil || m.monitorPrompt.input.Focused() || m.monitorPrompt.input.Value() != "" { + t.Fatal("stale editor fell through or retained draft") + } + m.focusMonitorPrompt() + m.stepBackMonitorContext() + if m.monitorPrompt.input.Value() != "" || m.monitorPrompt.input.Focused() { + t.Fatal("draft leaked to another mode") + } +} + +func TestMonitorPromptResponsiveClickTargets(t *testing.T) { + lang := i18n.Code() + for _, size := range [][2]int{{24, 12}, {40, 16}, {80, 24}, {120, 40}, {180, 50}} { + m, _ := promptTestModel() + m.width, m.height = size[0], size[1] + out := m.render() + if lipgloss.Width(out) > m.width || lipgloss.Height(out) > m.height { + t.Fatalf("overflow %s %v", lang, size) + } + g := m.dashboardLayout() + rows := m.monitorPromptRows(g.contentWidth, g.meterHeight) + if rows == 0 { + continue + } + _, _, y := monitorContextBodyLayout(g.meterHeight, rows) + y += g.meterY + 1 + for x := 4; x < g.contentWidth; x++ { + if m.monitorContextAt(x, y) != "prompt" { + t.Fatalf("miss %s %v %d,%d", lang, size, x, y) + } + } + n, _ := m.Update(tea.MouseClickMsg(tea.Mouse{X: 4, Y: y, Button: tea.MouseLeft})) + if !n.(Model).monitorPrompt.input.Focused() { + t.Fatal("click did not focus") + } + } +} + +func TestMonitorPromptSourceAndChangedRequest(t *testing.T) { + m, c := promptTestModel() + m.monitorSessionData[0].preview.ThreadID = "child" + if got := m.monitorPromptOffer(); got.ThreadID != "root-one" { + t.Fatal("follow-up targeted child", got) + } + c.offer.ThreadID = "child" + c.offer.Questions = []codex.PromptQuestion{{ID: "q", Text: "Question", FreeText: true}} + m.monitorSessionData[0].preview.Kind = codex.SessionContextQuestion + if m.monitorPromptOffer().Token != "" { + t.Fatal("unseen question actionable") + } + m.monitorSessionData[0].preview.InputToken = c.offer.Token + if got := m.monitorPromptOffer(); got.ThreadID != "child" { + t.Fatal("question did not target child", got) + } + m.focusMonitorPrompt() + c.offer.Token = "replacement" + if m.monitorPromptOffer().Token != "" { + t.Fatal("new question answered with old displayed context") + } + m.monitorSessionData[0].preview.Kind = codex.SessionContextApproval + m.monitorPrompt.notice = "previous outcome" + g := m.dashboardLayout() + if m.monitorPromptRows(g.contentWidth, g.meterHeight) != 0 { + t.Fatal("prompt notice overlaps approval footer") + } +} diff --git a/intro-post.md b/intro-post.md index 9121633..2f2151a 100644 --- a/intro-post.md +++ b/intro-post.md @@ -36,7 +36,7 @@ API-equivalent figures are workload-dependent estimates, not your subscription The reset button normally appears only when Codex reports an available reset and a quota window is at least **80% consumed**. Change that threshold with `--reset-threshold 60`. Click once to reveal confirmation, then again within ten seconds to redeem; `Esc` cancels. A reset refreshes eligible quota and changes the weekly reset schedule—it does not stack additional allowance. -Ordinary Codex CLI sessions work out of the box. For the best Monitor feedback, connect your CLI sessions through a shared local Codex app-server: Codexometer can distinguish **INPUT NEEDED** from **APPROVAL NEEDED**, show command-approval details with confirmed **Approve once** and **Decline** controls for supported requests, and use positively matched resolved-model events for more accurate pricing. Without that setup, it falls back to local session signals and a cautious **CHECK SESSION** inactivity prompt, not a guessed approval alert. See the [recommended setup](https://github.com/merefield/codexometer#recommended-codex-cli-setup). +Ordinary Codex CLI sessions work out of the box. For the best Monitor feedback, connect your CLI sessions through a shared local Codex app-server: Codexometer can distinguish **INPUT NEEDED** from **APPROVAL NEEDED**, show command-approval details with controls matching Codex's supported offered choices, and use positively matched resolved-model events for more accurate pricing. Permission grants require confirmation; session-wide and persistent-prefix choices are clearly labelled. Without that setup, it falls back to local session signals and a cautious **CHECK SESSION** inactivity prompt, not a guessed approval alert. See the [recommended setup](https://github.com/merefield/codexometer#recommended-codex-cli-setup). ## Make it yours @@ -91,7 +91,7 @@ Prefer a non-interactive run? `codexometer --digbench-game P-1` runs a named gam ## Privacy, authentication, and cost -Quota monitoring uses the prevailing Codex login. Monitor can show a bounded last-reply, activity, question, or approval preview beside each session, with an `[i]` detail view. Excerpts remain in memory and do not trigger another model call; `h` hides them, and that display preference is remembered. Questions and unsupported approvals are answered in Codex. Supported shared-server command approvals require explicit clicks; no persistent permissions are granted. Full interaction capture remains restricted to isolated benchmark turns created by Codexometer; Monitor does not retain user prompts, reasoning, or arbitrary tool output. +Quota monitoring uses the prevailing Codex login. Monitor can show a bounded last-reply, activity, question, or approval preview beside each session, with an `[i]` detail view. Excerpts remain in memory and do not trigger another model call; `h` hides them, and that display preference is remembered. Questions and unsupported approvals are answered in Codex. Supported shared-server command decisions require explicit clicks; broader grants require a separately labelled confirmation and persistent-prefix rules are shown for review. Full interaction capture remains restricted to isolated benchmark turns created by Codexometer; Monitor does not retain user prompts, reasoning, or arbitrary tool output. Benchmark transcripts are bounded and sanitized. Credentials, request headers, known runtime identifiers, temporary paths, terminal controls, Codex reasoning, and unrelated local session content are not retained in the detail view. diff --git a/main.go b/main.go index 6e0eddf..3d11434 100644 --- a/main.go +++ b/main.go @@ -119,6 +119,8 @@ func (d *demoFetcher) FetchTokenUsage(context.Context) (codex.LiveUsageSnapshot, alphaTokens := d.lifetimeTokens * 3 / 5 attention := codex.SessionAttentionApproval preview := codex.SessionContext{Kind: codex.SessionContextApproval, Text: "DEMO ONLY — no command will run.\nAllow pushing the documentation update?\nCommand: git push origin main\nDirectory: /projects/alpha", ThreadID: "019d-demo-a1b2c", Source: "DEMO", At: time.Now().Add(-time.Minute), ApprovalToken: "demo-command"} + preview.ApprovalOptions = [8]codex.ApprovalOption{{Kind: "accept", Value: "accept", Wire: `"accept"`}, {Kind: "acceptForSession", Value: "acceptForSession", Wire: `"acceptForSession"`}, {Kind: "cancel", Value: "cancel", Wire: `"cancel"`}} + preview.ApprovalDecisions = "accept, acceptForSession, cancel" if d.approvalDecision != "" { attention = codex.SessionAttentionNone preview.Kind = codex.SessionContextReply @@ -133,7 +135,7 @@ func (d *demoFetcher) FetchTokenUsage(context.Context) (codex.LiveUsageSnapshot, {ID: "019d-demo-a1b2c", WorkingDirectory: "/projects/alpha", TotalTokens: alphaTokens, LastActivity: time.Now(), AgentCount: 2, Active: true, Attention: attention, Context: preview, ModelCalls: append([]codex.LiveModelCall(nil), d.alphaCalls...), TurnTimings: append([]codex.LiveTurnTiming(nil), d.alphaTurns...)}, {ID: "019d-demo-d4e5f", WorkingDirectory: "/projects/bravo", TotalTokens: d.lifetimeTokens - alphaTokens, LastActivity: time.Now(), Active: true, - Attention: codex.SessionAttentionInput, Context: codex.SessionContext{Kind: codex.SessionContextReply, Text: "Pushed directly to main.\nIncludes both README and intro-post updates.", ThreadID: "019d-demo-d4e5f", Source: "DEMO", At: time.Now().Add(-2 * time.Minute)}, ModelCalls: append([]codex.LiveModelCall(nil), d.bravoCalls...), TurnTimings: append([]codex.LiveTurnTiming(nil), d.bravoTurns...)}, + Attention: codex.SessionAttentionComplete, Context: codex.SessionContext{Kind: codex.SessionContextReply, Text: "Pushed directly to main.\nIncludes both README and intro-post updates.", ThreadID: "019d-demo-d4e5f", Source: "DEMO", At: time.Now().Add(-2 * time.Minute)}, ModelCalls: append([]codex.LiveModelCall(nil), d.bravoCalls...), TurnTimings: append([]codex.LiveTurnTiming(nil), d.bravoTurns...)}, }, }, nil } @@ -152,7 +154,7 @@ func (d *demoFetcher) RespondSessionApproval(ctx context.Context, token, decisio if err := ctx.Err(); err != nil { return err } - if token != "demo-command" || d.approvalDecision != "" || (decision != "accept" && decision != "decline") { + if token != "demo-command" || d.approvalDecision != "" || (decision != "accept" && decision != "acceptForSession" && decision != "cancel") { return fmt.Errorf("invalid or resolved demo approval") } d.approvalDecision = decision diff --git a/main_test.go b/main_test.go index 6928647..3daf146 100644 --- a/main_test.go +++ b/main_test.go @@ -36,8 +36,8 @@ func TestDemoCommandApprovalIsOneUseSimulation(t *testing.T) { if !d.SessionApprovalPending(token) { t.Fatal("demo approval missing") } - if err := d.RespondSessionApproval(context.Background(), token, "acceptForSession"); err == nil { - t.Fatal("demo accepted persistent grant") + if err := d.RespondSessionApproval(context.Background(), token, "unknown"); err == nil { + t.Fatal("demo accepted unknown decision") } if err := d.RespondSessionApproval(context.Background(), token, "accept"); err != nil { t.Fatal(err) From b0a11afdf5f3def931cbdc62041680c4839a740b Mon Sep 17 00:00:00 2001 From: merefield Date: Sun, 6 Sep 2026 16:17:15 +0100 Subject: [PATCH 3/9] UI: separate approval justification from command in detail view --- internal/ui/monitor_context.go | 15 +++++++++++++- internal/ui/monitor_context_test.go | 31 +++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/internal/ui/monitor_context.go b/internal/ui/monitor_context.go index 9186f24..2e547bc 100644 --- a/internal/ui/monitor_context.go +++ b/internal/ui/monitor_context.go @@ -135,7 +135,20 @@ func (m Model) contextDetailLines(width int) []string { if s.preview.ApprovalDecisions != "" { lines = append(lines, strings.Split(ansi.Hardwrap(i18n.Text("OFFERED DECISIONS")+" // "+s.preview.ApprovalDecisions, max(width, 1), true), "\n")...) } - return append(lines, strings.Split(ansi.Hardwrap(codex.SanitizeSessionContext(s.preview.Text), max(width, 1), true), "\n")...) + text := codex.SanitizeSessionContext(s.preview.Text) + if s.preview.Kind == codex.SessionContextApproval { + // Presentation-only spacing: preserve the exact request/capability and + // command text, including multiline commands, for approval validation. + parts := strings.Split(text, "\n") + for i, line := range parts { + if i > 0 && strings.HasPrefix(line, "Command: ") && strings.TrimSpace(parts[i-1]) != "" { + parts[i] = "\n" + line + break + } + } + text = strings.Join(parts, "\n") + } + return append(lines, strings.Split(ansi.Hardwrap(text, max(width, 1), true), "\n")...) } func (m Model) renderMonitorContextDetail(width, height int, colors palette) string { diff --git a/internal/ui/monitor_context_test.go b/internal/ui/monitor_context_test.go index 869eebc..24123ba 100644 --- a/internal/ui/monitor_context_test.go +++ b/internal/ui/monitor_context_test.go @@ -21,6 +21,37 @@ func contextTestModel() Model { return m } +func TestApprovalDetailSeparatesJustificationFromCommand(t *testing.T) { + m := approvalTestModel() + for _, text := range []string{ + "Please check the repository.\nCommand: git status\nDirectory: /work", + "Please check the repository.\n\nCommand: git status\nDirectory: /work", + } { + m.monitorSessionData[0].preview.Text = text + before := m.monitorSessionData[0].preview + for _, width := range []int{20, 100} { + lines := m.contextDetailLines(width) + found := false + for i, line := range lines { + if strings.HasPrefix(line, "Command:") { + found = true + if i < 2 || lines[i-1] != "" || lines[i-2] == "" { + t.Fatalf("expected one spacer at width %d: %q", width, lines) + } + } + } + if !found || m.monitorSessionData[0].preview != before { + t.Fatal("command missing or request mutated") + } + } + } + m.monitorSessionData[0].preview.Kind = codex.SessionContextReply + m.monitorSessionData[0].preview.Text = "Example\nCommand: untouched" + if got := strings.Join(m.contextDetailLines(100), "\n"); !strings.Contains(got, "Example\nCommand: untouched") { + t.Fatal("ordinary reply reformatted", got) + } +} + func TestMonitorCompletionIsIndependentOfPreview(t *testing.T) { m := contextTestModel() m.monitorSessionData[0].attention = codex.SessionAttentionComplete From 4d9334cb54ebc9f8073de14eacc0a268091a05e4 Mon Sep 17 00:00:00 2001 From: merefield Date: Sun, 6 Sep 2026 16:33:23 +0100 Subject: [PATCH 4/9] FEAT: refine Monitor detail formatting and keyboard interactions --- README.md | 29 ++++- internal/codex/session_approval_test.go | 13 ++ internal/codex/session_context.go | 9 ++ internal/codex/session_context_daemon.go | 1 + internal/i18n/locales/de.json | 4 + internal/i18n/locales/en-GB.json | 4 + internal/i18n/locales/es.json | 4 + internal/i18n/locales/fr.json | 4 + internal/i18n/locales/it.json | 4 + internal/i18n/locales/ja.json | 4 + internal/i18n/locales/nl.json | 4 + internal/i18n/locales/ru.json | 4 + internal/i18n/locales/zh-Hans.json | 4 + internal/ui/localisation_test.go | 2 + internal/ui/monitor_approval.go | 63 +++++++++- internal/ui/monitor_approval_test.go | 82 ++++++++++++- internal/ui/monitor_context.go | 47 ++------ internal/ui/monitor_context_modes_test.go | 2 +- internal/ui/monitor_detail.go | 120 +++++++++++++++++++ internal/ui/monitor_detail_test.go | 64 ++++++++++ internal/ui/monitor_editor.go | 138 ++++++++++++++++++++++ internal/ui/monitor_prompt.go | 45 ++++--- internal/ui/monitor_prompt_test.go | 74 +++++++++++- main.go | 1 + 24 files changed, 658 insertions(+), 68 deletions(-) create mode 100644 internal/ui/monitor_detail.go create mode 100644 internal/ui/monitor_detail_test.go create mode 100644 internal/ui/monitor_editor.go diff --git a/README.md b/README.md index 89b4cc6..eea3413 100644 --- a/README.md +++ b/README.md @@ -1039,8 +1039,13 @@ COMPLETE**), or has a supported blocking question. Click the input line or press `Enter` to focus it; type your text, then press `Enter` to submit. Dashboard hotkeys become ordinary letters while typing. `Esc` leaves the editor without sending; press it again to return to the expanded row. Cursor editing, Unicode -and bracketed paste are supported; pasted newlines become spaces and never -submit. This is a single-line text editor, not the Codex slash-command UI. +and bracketed paste are supported; pasted newlines are preserved and never +submit. Text wraps automatically and the editor grows upward, reducing the +scrollable context area above it. Once it reaches the available height, the +editor scrolls internally without truncating the draft; deleting text shrinks +it again. Enter still submits rather than inserting a newline. This is a text +editor, not the Codex slash-command UI. Secret answers retain a single-line +masked password field. For a question with multiple parts, `Enter` records each answer locally; only after the last answer is the complete response sent. Use `↑`/`↓` to select offered @@ -1068,7 +1073,14 @@ do not offer an editor; use Codex itself in those cases. Codexometer does not detect the CLI's visual keyboard-focus state or type into its terminal. **Command approvals (shared app-server only):** the detail page shows the reason, -command and working directory. When the approval event omits the command or +command and working directory. The full detail view presents distinct justification, command, +working-directory and persistent-rule sections, with themed headings, muted +metadata and blank separators. Commands retain their line breaks and are +visually marked with a vertical rail. Formatting uses validated structured +fields rather than guessing command boundaries from the justification. Legacy +or incomplete requests retain their original bounded text. + +When the approval event omits the command or directory, Codexometer associates it with the preceding command item from the same thread, turn and item. A complete ordinary command request offers clickable buttons matching the supported decisions Codex actually offers, in its order: @@ -1092,8 +1104,15 @@ command-prefix grants are validated and their exact payload is returned; no decision absent from the request can be submitted. For older servers omitting the decision list, only the legacy Accept/Cancel pair is offered. -Closing the detail page cancels an unsubmitted confirmation. There are no -approval keyboard shortcuts. Buttons wrap into rows when needed, reserve their +Closing the detail page cancels an unsubmitted confirmation. Each visible +approval button has a numbered shortcut (`1`–`8`, following the offered order). +A grant shortcut selects the choice; `C` confirms that specific choice, whether +selected by keyboard or mouse. Repeating the number does not confirm. Decline +and reject/stop shortcuts act immediately, just like their buttons. Only one +session—the expanded row or full-detail target—can display approval controls +at a time. Hidden, clipped and compact controls have no active shortcuts, and +typing in the reply editor never triggers approval shortcuts. +Buttons wrap into rows when needed, reserve their confirmation widths so other targets do not move, and sit in a pinned footer below the scrolling request text, separated by one blank line when space allows. Very short terminals omit that spacer first. Controls are hidden if the terminal diff --git a/internal/codex/session_approval_test.go b/internal/codex/session_approval_test.go index 5d52aef..6496ef5 100644 --- a/internal/codex/session_approval_test.go +++ b/internal/codex/session_approval_test.go @@ -28,6 +28,9 @@ func TestApprovalCompleteCommandAndFailClosed(t *testing.T) { if c.ApprovalToken == "" || !strings.Contains(c.Text, "Command: git push\nDirectory: /work") { t.Fatalf("missing correlated detail: %+v", c) } + if c.CommandDetails != (ApprovalCommandDetails{Justification: "Publish branch?", Command: "git push", Directory: "/work"}) { + t.Fatal("structured fields did not preserve source values", c.CommandDetails) + } for name, fields := range map[string]map[string]any{ "wrong turn": {"turnId": "other"}, "wrong item": {"itemId": "other"}, @@ -45,10 +48,20 @@ func TestApprovalCompleteCommandAndFailClosed(t *testing.T) { if c.ApprovalToken != "" { t.Fatalf("unsafe request actionable: %+v", c) } + if c.CommandDetails != (ApprovalCommandDetails{}) { + t.Fatal("unvalidated request exposed structured command") + } }) } } +func TestApprovalStructuredJustificationIsBounded(t *testing.T) { + _, c := approvalFixture(t, map[string]any{"reason": strings.Repeat(" ", 10000) + "Explanation\nCommand: not the real command"}) + if c.ApprovalToken == "" || c.CommandDetails.Command != "git push" || c.CommandDetails.Justification != "Explanation\nCommand: not the real command" { + t.Fatal("justification confused command fields", c.CommandDetails) + } +} + func TestApprovalLifecycleAndReplay(t *testing.T) { for _, method := range []string{"serverRequest/resolved", "turn/started", "turn/completed", "turn/interrupted", "thread/closed", "item/completed"} { t.Run(method, func(t *testing.T) { diff --git a/internal/codex/session_context.go b/internal/codex/session_context.go index 2f7921a..e7f0c6d 100644 --- a/internal/codex/session_context.go +++ b/internal/codex/session_context.go @@ -25,6 +25,7 @@ const ( // SessionContext is a bounded, memory-only excerpt, never a generated summary. // It is deliberately separate from token accounting and persisted preferences. type SessionContext struct { + CommandDetails ApprovalCommandDetails InputToken string InputQuestions string // ApprovalToken is an opaque, connection-local capability, never persisted. @@ -42,6 +43,14 @@ type SessionContext struct { Source string } +// Structured display fields are populated only alongside a complete validated +// command request. Never infer command boundaries from justification prose. +type ApprovalCommandDetails struct { + Justification string + Command string + Directory string +} + const sessionContextLimit = 4096 // SanitizeSessionContext removes terminal escapes and control/bidi formatting. diff --git a/internal/codex/session_context_daemon.go b/internal/codex/session_context_daemon.go index 94ef191..225cbf5 100644 --- a/internal/codex/session_context_daemon.go +++ b/internal/codex/session_context_daemon.go @@ -122,6 +122,7 @@ func daemonContextEvent(states map[string]*daemonContextState, method string, id c.ApprovalBlocked = "sanitised" default: c.ApprovalToken = rand.Text() + c.CommandDetails = ApprovalCommandDetails{Justification: SanitizeSessionContext(p.Reason), Command: command, Directory: p.CWD} } case "item/fileChange/requestApproval": c.ApprovalBlocked = "file-change" diff --git a/internal/i18n/locales/de.json b/internal/i18n/locales/de.json index 1de6cea..39af791 100644 --- a/internal/i18n/locales/de.json +++ b/internal/i18n/locales/de.json @@ -1,4 +1,8 @@ { + "JUSTIFICATION": "BEGRÜNDUNG", + "COMMAND": "BEFEHL", + "WORKING DIRECTORY": "ARBEITSVERZEICHNIS", + "PERSISTENT PERMISSION RULE": "DAUERHAFTE BERECHTIGUNGSREGEL", "FOLLOW-UP": "NACHFRAGE", "REPLY": "ANTWORT", "[ Click here or press Enter to write ]": "[ Hier klicken oder Enter zum Schreiben ]", diff --git a/internal/i18n/locales/en-GB.json b/internal/i18n/locales/en-GB.json index f583837..284fadb 100644 --- a/internal/i18n/locales/en-GB.json +++ b/internal/i18n/locales/en-GB.json @@ -1,4 +1,8 @@ { + "JUSTIFICATION": "JUSTIFICATION", + "COMMAND": "COMMAND", + "WORKING DIRECTORY": "WORKING DIRECTORY", + "PERSISTENT PERMISSION RULE": "PERSISTENT PERMISSION RULE", "FOLLOW-UP": "FOLLOW-UP", "REPLY": "REPLY", "[ Click here or press Enter to write ]": "[ Click here or press Enter to write ]", diff --git a/internal/i18n/locales/es.json b/internal/i18n/locales/es.json index 207fc85..162581a 100644 --- a/internal/i18n/locales/es.json +++ b/internal/i18n/locales/es.json @@ -1,4 +1,8 @@ { + "JUSTIFICATION": "JUSTIFICACIÓN", + "COMMAND": "COMANDO", + "WORKING DIRECTORY": "DIRECTORIO DE TRABAJO", + "PERSISTENT PERMISSION RULE": "REGLA DE PERMISO PERSISTENTE", "FOLLOW-UP": "SEGUIMIENTO", "REPLY": "RESPUESTA", "[ Click here or press Enter to write ]": "[ Haz clic aquí o pulsa Intro para escribir ]", diff --git a/internal/i18n/locales/fr.json b/internal/i18n/locales/fr.json index ed49168..8f2c474 100644 --- a/internal/i18n/locales/fr.json +++ b/internal/i18n/locales/fr.json @@ -1,4 +1,8 @@ { + "JUSTIFICATION": "JUSTIFICATION", + "COMMAND": "COMMANDE", + "WORKING DIRECTORY": "RÉPERTOIRE DE TRAVAIL", + "PERSISTENT PERMISSION RULE": "RÈGLE D’AUTORISATION PERSISTANTE", "FOLLOW-UP": "SUIVI", "REPLY": "RÉPONSE", "[ Click here or press Enter to write ]": "[ Cliquez ici ou Entrée pour écrire ]", diff --git a/internal/i18n/locales/it.json b/internal/i18n/locales/it.json index d033f93..f053885 100644 --- a/internal/i18n/locales/it.json +++ b/internal/i18n/locales/it.json @@ -1,4 +1,8 @@ { + "JUSTIFICATION": "MOTIVAZIONE", + "COMMAND": "COMANDO", + "WORKING DIRECTORY": "CARTELLA DI LAVORO", + "PERSISTENT PERMISSION RULE": "REGOLA DI AUTORIZZAZIONE PERMANENTE", "FOLLOW-UP": "SEGUITO", "REPLY": "RISPOSTA", "[ Click here or press Enter to write ]": "[ Clicca qui o premi Invio per scrivere ]", diff --git a/internal/i18n/locales/ja.json b/internal/i18n/locales/ja.json index ac8a5fa..ea68574 100644 --- a/internal/i18n/locales/ja.json +++ b/internal/i18n/locales/ja.json @@ -1,4 +1,8 @@ { + "JUSTIFICATION": "理由", + "COMMAND": "コマンド", + "WORKING DIRECTORY": "作業ディレクトリ", + "PERSISTENT PERMISSION RULE": "永続的な権限ルール", "FOLLOW-UP": "続けて質問", "REPLY": "回答", "[ Click here or press Enter to write ]": "[ クリックまたは Enter で入力 ]", diff --git a/internal/i18n/locales/nl.json b/internal/i18n/locales/nl.json index 26dba5d..d3f2e00 100644 --- a/internal/i18n/locales/nl.json +++ b/internal/i18n/locales/nl.json @@ -1,4 +1,8 @@ { + "JUSTIFICATION": "TOELICHTING", + "COMMAND": "OPDRACHT", + "WORKING DIRECTORY": "WERKMAP", + "PERSISTENT PERMISSION RULE": "BLIJVENDE TOESTEMMINGSREGEL", "FOLLOW-UP": "VERVOLG", "REPLY": "ANTWOORD", "[ Click here or press Enter to write ]": "[ Klik hier of druk op Enter om te typen ]", diff --git a/internal/i18n/locales/ru.json b/internal/i18n/locales/ru.json index 6dd9488..cfcbcf9 100644 --- a/internal/i18n/locales/ru.json +++ b/internal/i18n/locales/ru.json @@ -1,4 +1,8 @@ { + "JUSTIFICATION": "ОБОСНОВАНИЕ", + "COMMAND": "КОМАНДА", + "WORKING DIRECTORY": "РАБОЧИЙ КАТАЛОГ", + "PERSISTENT PERMISSION RULE": "ПОСТОЯННОЕ ПРАВИЛО РАЗРЕШЕНИЙ", "FOLLOW-UP": "ПРОДОЛЖЕНИЕ", "REPLY": "ОТВЕТ", "[ Click here or press Enter to write ]": "[ Нажмите здесь или Enter для ввода ]", diff --git a/internal/i18n/locales/zh-Hans.json b/internal/i18n/locales/zh-Hans.json index c10affd..2ba8f12 100644 --- a/internal/i18n/locales/zh-Hans.json +++ b/internal/i18n/locales/zh-Hans.json @@ -1,4 +1,8 @@ { + "JUSTIFICATION": "理由", + "COMMAND": "命令", + "WORKING DIRECTORY": "工作目录", + "PERSISTENT PERMISSION RULE": "持久权限规则", "FOLLOW-UP": "继续提问", "REPLY": "回复", "[ Click here or press Enter to write ]": "[ 点击此处或按 Enter 输入 ]", diff --git a/internal/ui/localisation_test.go b/internal/ui/localisation_test.go index 73aa7a5..35e09a1 100644 --- a/internal/ui/localisation_test.go +++ b/internal/ui/localisation_test.go @@ -51,6 +51,8 @@ func TestLocalisedScreensHelper(t *testing.T) { t.Run("monitor_approval_surfaces", TestMonitorApprovalRenderedTargets) t.Run("monitor_expanded_surfaces", TestExpandedContextResponsiveHitTargets) t.Run("monitor_prompt_surfaces", TestMonitorPromptResponsiveClickTargets) + t.Run("monitor_prompt_wrapping", TestMonitorPromptWrapsAndGrowsUpward) + t.Run("monitor_detail_sections", TestMonitorDetailStructuredSections) t.Run("tab_click_surfaces", TestEveryRenderedTabCellIsClickableAcrossWidths) t.Run("reset_click_surfaces", TestQuotaResetRenderedHitSurfaces) t.Run("quota_estimator", TestQuotaAPIEstimatorLearnsRangeAndCurrentSpend) diff --git a/internal/ui/monitor_approval.go b/internal/ui/monitor_approval.go index bbf9c8f..0b4ba04 100644 --- a/internal/ui/monitor_approval.go +++ b/internal/ui/monitor_approval.go @@ -100,6 +100,18 @@ type monitorApprovalButton struct { x, y, slot int } +func approvalShortcutLabel(kind string, confirm bool, index int) string { + label := approvalOptionLabel(kind, confirm) + if label == "" { + return "" + } + shortcut := strconv.Itoa(index + 1) + if confirm { + shortcut = "C" + } + return "[ (" + shortcut + ") " + strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(label, "["), "]")) + " ]" +} + // Rendering and hit testing share this layout. Slots reserve confirmation // widths so neighbouring decisions never move underneath a user's pointer. func (m Model) monitorApprovalButtons(width, height int) []monitorApprovalButton { @@ -117,8 +129,8 @@ func (m Model) monitorApprovalButtons(width, height int) []monitorApprovalButton if option.Kind == "" { continue } - label := approvalOptionLabel(option.Kind, false) - slot := max(lipgloss.Width(label), lipgloss.Width(approvalOptionLabel(option.Kind, true))) + label := approvalShortcutLabel(option.Kind, false, i) + slot := max(lipgloss.Width(label), lipgloss.Width(approvalShortcutLabel(option.Kind, true, i))) if slot > width-4 || label == "" { return nil } @@ -131,7 +143,7 @@ func (m Model) monitorApprovalButtons(width, height int) []monitorApprovalButton } action := "decision:" + strconv.Itoa(i) if m.monitorApprovalConfirm == token+"/"+action { - label = approvalOptionLabel(option.Kind, true) + label = approvalShortcutLabel(option.Kind, true, i) } buttons = append(buttons, monitorApprovalButton{action, label, x, y, slot}) x += slot + 2 @@ -139,6 +151,51 @@ func (m Model) monitorApprovalButtons(width, height int) []monitorApprovalButton return buttons } +// Shortcuts are enabled only for the buttons actually visible on the sole +// expanded/detail target, never for compact rows or clipped inline controls. +func (m Model) visibleMonitorApprovalButtons() []monitorApprovalButton { + if m.meterView != viewMonitor || m.monitorContextHidden || m.monitorPrompt.input.Focused() { + return nil + } + g := m.dashboardLayout() + if m.monitorContextDetail != "" { + return m.monitorApprovalButtons(g.contentWidth, g.meterHeight) + } + if m.monitorContextExpanded == "" || m.monitorSelectedID != "" && m.monitorSelectedID != m.monitorContextExpanded { + return nil + } + a := layoutMonitorArea(g.contentWidth, g.meterHeight) + sessions, heights, _ := m.monitorSessionPage(a.graphHeight) + for i, s := range sessions { + if s.id == m.monitorContextExpanded { + _, cw, _ := monitorSessionColumnWidths(a.width) + return m.expandedApprovalButtons(cw, heights[i], s) + } + } + return nil +} + +func (m Model) updateMonitorApprovalKey(key string) (Model, tea.Cmd, bool) { + if key != "c" && (len(key) != 1 || key[0] < '1' || key[0] > '8') { + return m, nil, false + } + buttons := m.visibleMonitorApprovalButtons() + token := m.monitorApprovalToken() + for _, b := range buttons { + if key == "c" { + if m.monitorApprovalConfirm == token+"/"+b.action { + return m.monitorApprovalAction(b.action) + } + } else if b.action == "decision:"+strconv.Itoa(int(key[0]-'1')) { + // Repeating a number must never turn selection into a grant. Only C + // or the explicitly relabelled confirmation button can confirm it. + m.monitorApprovalConfirm = "" + return m.monitorApprovalAction(b.action) + } + } + return m, nil, true +} + func (m Model) monitorApprovalControls(width, height int) bool { return len(m.monitorApprovalButtons(width, height)) > 0 } diff --git a/internal/ui/monitor_approval_test.go b/internal/ui/monitor_approval_test.go index d4a6057..12994bc 100644 --- a/internal/ui/monitor_approval_test.go +++ b/internal/ui/monitor_approval_test.go @@ -59,7 +59,7 @@ func TestMonitorApprovalRenderedTargets(t *testing.T) { continue } action := "decision:" + strconv.Itoa(i) - label := approvalOptionLabel(option.Kind, confirmed && i == 0) + label := approvalShortcutLabel(option.Kind, confirmed && i == 0, i) found := false for y, line := range strings.Split(ansi.Strip(out), "\n") { pos := strings.Index(line, label) @@ -123,6 +123,86 @@ func TestMonitorApprovalStaleAndKeyboardSafety(t *testing.T) { } } +func TestMonitorApprovalNumberSelectsCConfirms(t *testing.T) { + for _, index := range []int{0, 3, 4} { + m := approvalTestModel() + m, cmd, _ := m.updateMonitorContextKey("c") + if cmd != nil || m.monitorApprovalBusy { + t.Fatal("C granted without a selection") + } + for range 2 { + m, cmd, _ = m.updateMonitorContextKey(strconv.Itoa(index + 1)) + if cmd != nil || m.monitorApprovalBusy || m.monitorApprovalConfirm != "live/decision:"+strconv.Itoa(index) { + t.Fatal("number did not exclusively arm requested choice") + } + } + if !strings.Contains(ansi.Strip(m.render()), approvalShortcutLabel(m.monitorSessionData[0].preview.ApprovalOptions[index].Kind, true, index)) { + t.Fatal("C confirmation shortcut missing") + } + m, cmd, _ = m.updateMonitorContextKey("c") + if cmd == nil || !m.monitorApprovalBusy { + t.Fatal("C did not submit armed grant") + } + cmd() + if got := m.fetcher.(*approvalTestClient).decision; got != m.monitorSessionData[0].preview.ApprovalOptions[index].Value { + t.Fatal("wrong grant", got) + } + } + for _, index := range []int{1, 2} { + m := approvalTestModel() + m, cmd, _ := m.updateMonitorContextKey(strconv.Itoa(index + 1)) + if cmd == nil || !m.monitorApprovalBusy { + t.Fatal("negative decision shortcut did not match its button") + } + } +} + +func TestMonitorApprovalShortcutsHaveOneVisibleSession(t *testing.T) { + m := approvalTestModel() + m.width, m.height = 180, 60 + for i := range m.monitorSessionData { + m.monitorSessionData[i].preview = m.monitorSessionData[0].preview + } + for _, detail := range []bool{false, true} { + m.monitorContextDetail, m.monitorContextExpanded = "", "root-one" + if detail { + m.monitorContextDetail, m.monitorContextExpanded = "root-one", "" + } + buttons := m.visibleMonitorApprovalButtons() + if len(buttons) == 0 { + t.Fatal("fixture has no visible buttons") + } + out := ansi.Strip(m.render()) + for _, b := range buttons { + if strings.Count(out, b.label) != 1 { + t.Fatal("approval choice shown for multiple sessions", b.label) + } + } + m, _, _ = m.updateMonitorContextKey("1") + if m.monitorApprovalConfirm != "live/decision:0" { + t.Fatal("visible keyboard choice failed") + } + } + m.monitorContextDetail = "" + m.monitorContextExpanded = "root-one" + m.monitorSessionData[0].preview.Text = strings.Repeat("too long\n", 200) + for _, k := range []string{"1", "c", "2"} { + _, cmd, _ := m.updateMonitorContextKey(k) + if cmd != nil { + t.Fatal("invisible inline button actionable") + } + } + m.monitorSelectedID = "root-one" + m.selectMonitorSession(1) + if m.monitorApprovalConfirm != "" { + t.Fatal("selection retained old confirmation") + } + _, cmd, _ := m.updateMonitorContextKey("c") + if cmd != nil { + t.Fatal("C acted on previous session") + } +} + func TestMonitorApprovalExplainsUnavailableControls(t *testing.T) { m := approvalTestModel() s := &m.monitorSessionData[0] diff --git a/internal/ui/monitor_context.go b/internal/ui/monitor_context.go index 2e547bc..394efd0 100644 --- a/internal/ui/monitor_context.go +++ b/internal/ui/monitor_context.go @@ -120,45 +120,18 @@ func (m Model) contextDetailSession() (monitorSession, bool) { return monitorSession{}, false } -func (m Model) contextDetailLines(width int) []string { - s, ok := m.contextDetailSession() - if !ok || s.preview.Text == "" { - return []string{i18n.Text("NO CONTEXT")} - } - header := contextTitle(s.preview) + " // " + shortSessionID(s.preview.ThreadID) + " // " + s.preview.Source + " // " + contextAge(s.preview) - lines := []string{ansi.Truncate(header, max(width, 1), "")} - g := m.dashboardLayout() - if (s.preview.Kind == codex.SessionContextApproval || s.preview.Kind == codex.SessionContextQuestion) && !m.monitorApprovalControls(g.contentWidth, g.meterHeight) && m.monitorPromptRows(g.contentWidth, g.meterHeight) == 0 { - message := i18n.Text("REPLY IN CODEX") + " // " + m.monitorApprovalBlockReason(s.preview) - lines = append(lines, strings.Split(ansi.Hardwrap(message, max(width, 1), true), "\n")...) - } - if s.preview.ApprovalDecisions != "" { - lines = append(lines, strings.Split(ansi.Hardwrap(i18n.Text("OFFERED DECISIONS")+" // "+s.preview.ApprovalDecisions, max(width, 1), true), "\n")...) - } - text := codex.SanitizeSessionContext(s.preview.Text) - if s.preview.Kind == codex.SessionContextApproval { - // Presentation-only spacing: preserve the exact request/capability and - // command text, including multiline commands, for approval validation. - parts := strings.Split(text, "\n") - for i, line := range parts { - if i > 0 && strings.HasPrefix(line, "Command: ") && strings.TrimSpace(parts[i-1]) != "" { - parts[i] = "\n" + line - break - } - } - text = strings.Join(parts, "\n") - } - return append(lines, strings.Split(ansi.Hardwrap(text, max(width, 1), true), "\n")...) -} - func (m Model) renderMonitorContextDetail(width, height int, colors palette) string { - lines := m.contextDetailLines(max(width-4, 1)) + document := m.contextDetailDocument(max(width-4, 1)) + lines := make([]string, len(document)) + for i, line := range document { + lines[i] = line.render(colors) + } rows := max(height-2, 1) controls := "" if m.monitorApprovalControls(width, height) { controls = m.renderMonitorApprovalControls(width, height, colors) } else if m.monitorPromptRows(width, height) > 0 { - controls = m.renderMonitorPrompt(width, colors) + controls = m.renderMonitorPrompt(width, height, colors) } else if m.monitorApprovalNotice != "" { controls = colors.label().Render(ansi.Truncate(m.monitorApprovalNotice, max(width-4, 1), "")) } @@ -169,9 +142,6 @@ func (m Model) renderMonitorContextDetail(width, height int, colors palette) str textRows, gap, _ := monitorContextBodyLayout(height, controlRows) start := min(max(m.monitorContextScroll, 0), max(len(lines)-textRows, 0)) end := min(start+textRows, len(lines)) - for i := start; i < end; i++ { - lines[i] = colors.label().Render(lines[i]) - } bodyLines := append([]string(nil), lines[start:end]...) if controls != "" { for len(bodyLines) < textRows+gap { @@ -244,6 +214,9 @@ func (m *Model) scrollMonitorContext(delta int) { } func (m Model) updateMonitorContextKey(key string) (Model, tea.Cmd, bool) { + if next, cmd, handled := m.updateMonitorApprovalKey(key); handled { + return next, cmd, true + } if key == "h" { m.toggleMonitorContext() return m, nil, true @@ -302,7 +275,7 @@ func (m Model) monitorContextAt(x, y int) string { if m.monitorContextDetail != "" && !m.monitorContextHidden { if rows := m.monitorPromptRows(g.contentWidth, g.meterHeight); rows > 0 && m.monitorPromptOffer().Token != "" { _, _, controlY := monitorContextBodyLayout(g.meterHeight, rows) - if y == controlY+1 && x >= 2 && x < g.contentWidth-2 { + if y >= controlY+1 && y < controlY+rows-1 && x >= 2 && x < g.contentWidth-2 { return "prompt" } } diff --git a/internal/ui/monitor_context_modes_test.go b/internal/ui/monitor_context_modes_test.go index 7aa91f3..ed50369 100644 --- a/internal/ui/monitor_context_modes_test.go +++ b/internal/ui/monitor_context_modes_test.go @@ -103,7 +103,7 @@ func TestExpandedContextResponsiveHitTargets(t *testing.T) { m.monitorSessionData = append(m.monitorSessionData, s) } m.monitorSessionData = m.monitorSessionData[:size.n] - if strings.Contains(ansi.Strip(m.render()), approvalOptionLabel("accept", false)) { + if strings.Contains(ansi.Strip(m.render()), approvalShortcutLabel("accept", false, 0)) { t.Fatal("compact exposed approval") } m.cycleMonitorContext(m.monitorSessionData[0].id) diff --git a/internal/ui/monitor_detail.go b/internal/ui/monitor_detail.go new file mode 100644 index 0000000..4cb29e9 --- /dev/null +++ b/internal/ui/monitor_detail.go @@ -0,0 +1,120 @@ +package ui + +import ( + "strings" + + "charm.land/lipgloss/v2" + "github.com/charmbracelet/x/ansi" + "github.com/merefield/codexometer/internal/codex" + "github.com/merefield/codexometer/internal/i18n" +) + +type detailLine struct { + text string + kind string +} + +func (line detailLine) render(colors palette) string { + style := colors.label() + switch line.kind { + case "heading": + style = style.Bold(true).Foreground(colors.primary) + case "metadata": + style = colors.dimmed() + case "warning": + style = style.Foreground(colors.warning) + case "command": + style = style.Foreground(colors.primary) + } + return style.Render(line.text) +} + +// A single document owns both display and scroll geometry. Formatting never +// changes the stored request, capability or text sent back to Codex. +func (m Model) contextDetailDocument(width int) []detailLine { + width = max(width, 1) + s, ok := m.contextDetailSession() + if !ok || s.preview.Text == "" { + return []detailLine{{i18n.Text("NO CONTEXT"), "metadata"}} + } + c := s.preview + var lines []detailLine + appendText := func(text, kind, prefix string) { + for _, line := range strings.Split(ansi.Hardwrap(text, max(width-lipgloss.Width(prefix), 1), true), "\n") { + // A double-width glyph cannot fit a one-cell viewport. Clip that + // degenerate case; normal widths retain every wrapped character. + lines = append(lines, detailLine{ansi.Truncate(prefix+line, width, ""), kind}) + } + } + section := func(title string) { + if len(lines) > 0 { + lines = append(lines, detailLine{}) + } + title = ansi.Truncate(title, width, "") + rule := max(width-lipgloss.Width(title)-1, 0) + if rule > 0 { + title += " " + strings.Repeat("─", rule) + } + lines = append(lines, detailLine{title, "heading"}) + } + if status := monitorSessionAttentionLabel(s); status != "" { + appendText(status, "heading", "") + } + appendText(c.Source+" // "+c.ThreadID+" // "+contextAge(c), "metadata", "") + g := m.dashboardLayout() + if (c.Kind == codex.SessionContextApproval || c.Kind == codex.SessionContextQuestion) && !m.monitorApprovalControls(g.contentWidth, g.meterHeight) && m.monitorPromptRows(g.contentWidth, g.meterHeight) == 0 { + section(i18n.Text("REPLY IN CODEX")) + appendText(m.monitorApprovalBlockReason(c), "warning", "") + } + if c.Kind == codex.SessionContextApproval && c.CommandDetails.Command != "" { + d := c.CommandDetails + if strings.TrimSpace(d.Justification) != "" { + section(i18n.Text("JUSTIFICATION")) + appendText(codex.SanitizeSessionContext(d.Justification), "body", "") + } + section(i18n.Text("COMMAND")) + prefix := "│ " + if width < 3 { + prefix = "" + } + appendText(codex.SanitizeSessionContext(d.Command), "command", prefix) + section(i18n.Text("WORKING DIRECTORY")) + appendText(codex.SanitizeSessionContext(d.Directory), "metadata", "") + for _, option := range c.ApprovalOptions { + if option.Detail != "" { + section(i18n.Text("PERSISTENT PERMISSION RULE")) + appendText(codex.SanitizeSessionContext(option.Detail), "warning", "") + } + } + } else { + section(contextTitle(c)) + text := codex.SanitizeSessionContext(c.Text) + // Legacy/unstructured requests remain verbatim. This cosmetic spacer + // does not claim to identify an authoritative command boundary. + if c.Kind == codex.SessionContextApproval { + parts := strings.Split(text, "\n") + for i, line := range parts { + if i > 0 && strings.HasPrefix(line, "Command: ") && strings.TrimSpace(parts[i-1]) != "" { + parts[i] = "\n" + line + break + } + } + text = strings.Join(parts, "\n") + } + appendText(text, "body", "") + } + if c.ApprovalDecisions != "" { + section(i18n.Text("OFFERED DECISIONS")) + appendText(c.ApprovalDecisions, "metadata", "") + } + return lines +} + +func (m Model) contextDetailLines(width int) []string { + doc := m.contextDetailDocument(width) + lines := make([]string, len(doc)) + for i, line := range doc { + lines[i] = line.text + } + return lines +} diff --git a/internal/ui/monitor_detail_test.go b/internal/ui/monitor_detail_test.go new file mode 100644 index 0000000..d79418a --- /dev/null +++ b/internal/ui/monitor_detail_test.go @@ -0,0 +1,64 @@ +package ui + +import ( + "strings" + "testing" + + "charm.land/lipgloss/v2" + "github.com/charmbracelet/x/ansi" + "github.com/merefield/codexometer/internal/codex" + "github.com/merefield/codexometer/internal/i18n" +) + +func TestMonitorDetailStructuredSections(t *testing.T) { + m := approvalTestModel() + c := &m.monitorSessionData[0].preview + c.CommandDetails = codex.ApprovalCommandDetails{ + Justification: "Check the repository.\nCommand: this is just explanatory prose.", + Command: "printf 'Directory: not metadata'\nprintf ok", + Directory: "/work/project", + } + c.ApprovalOptions[4].Detail = `["printf"]` + c.ApprovalDecisions = "accept, acceptForSession, cancel" + before := *c + for _, width := range []int{1, 20, 80, 120} { + doc := m.contextDetailDocument(width) + for _, line := range doc { + if lipgloss.Width(line.text) > width { + t.Fatalf("line overflow at %d: %q", width, line.text) + } + } + if width < 80 { + continue + } + var command []string + for _, line := range doc { + if line.kind == "command" { + command = append(command, strings.TrimPrefix(line.text, "│ ")) + } + } + if strings.Join(command, "\n") != c.CommandDetails.Command { + t.Fatal("command text/line breaks changed", command) + } + plain := strings.Join(m.contextDetailLines(width), "\n") + for _, label := range []string{i18n.Text("JUSTIFICATION"), i18n.Text("COMMAND"), i18n.Text("WORKING DIRECTORY"), i18n.Text("PERSISTENT PERMISSION RULE"), i18n.Text("OFFERED DECISIONS")} { + if !strings.Contains(plain, "\n\n"+label) { + t.Fatal("section lacks separation", label, plain) + } + } + if !strings.Contains(plain, c.CommandDetails.Justification) { + t.Fatal("justification split by untrusted label") + } + } + if *c != before { + t.Fatal("formatting changed request or capability") + } + for theme := themeHacker; theme < themeCount; theme++ { + colors := paletteFor(theme) + meta := (detailLine{"same", "metadata"}).render(colors) + head := (detailLine{"same", "heading"}).render(colors) + if meta == head || ansi.Strip(meta) != "same" || ansi.Strip(head) != "same" { + t.Fatal("section styles indistinguishable") + } + } +} diff --git a/internal/ui/monitor_editor.go b/internal/ui/monitor_editor.go new file mode 100644 index 0000000..c761d5d --- /dev/null +++ b/internal/ui/monitor_editor.go @@ -0,0 +1,138 @@ +package ui + +import ( + "charm.land/bubbles/v2/textarea" + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" +) + +// The textarea owns wrapping and cursor scrolling. Password questions keep +// the dedicated masked widget: textarea does not provide password echo. +type monitorEditor struct { + area textarea.Model + password textinput.Model + secret bool + ready bool +} + +func newMonitorEditor() monitorEditor { + a := textarea.New() + a.ShowLineNumbers = false + a.Prompt = "> " + a.CharLimit = 4096 + a.MaxWidth = 0 + a.DynamicHeight = true + a.MinHeight = 1 + a.MaxContentHeight = 8192 // viewport cap must not truncate the draft + a.SetHeight(1) + a.KeyMap.InsertNewline.SetEnabled(false) // Enter is handled as submit + p := textinput.New() + p.CharLimit = 4096 + p.EchoMode = textinput.EchoPassword + return monitorEditor{area: a, password: p, ready: true} +} + +func (e *monitorEditor) configure(width, height int) { + if !e.ready { + return + } + e.area.MaxHeight = max(height-8, 1) + e.area.SetWidth(max(width-4, 1)) + e.password.SetWidth(max(width-6, 1)) +} +func (e *monitorEditor) setSecret(secret bool) { + if e.secret == secret { + return + } + focused := e.Focused() + e.Reset() + e.Blur() + e.secret = secret + if focused { + e.Focus() + } +} +func (e monitorEditor) Value() string { + if !e.ready { + return "" + } + if e.secret { + return e.password.Value() + } + return e.area.Value() +} +func (e *monitorEditor) SetValue(s string) { + if e.secret { + e.password.SetValue(s) + } else { + e.area.SetValue(s) + } +} +func (e *monitorEditor) Reset() { + if !e.ready { + return + } + e.area.Reset() + e.password.Reset() +} +func (e monitorEditor) Focused() bool { + if !e.ready { + return false + } + if e.secret { + return e.password.Focused() + } + return e.area.Focused() +} +func (e *monitorEditor) Focus() tea.Cmd { + if e.secret { + return e.password.Focus() + } + return e.area.Focus() +} +func (e *monitorEditor) Blur() { + if !e.ready { + return + } + e.area.Blur() + e.password.Blur() +} +func (e *monitorEditor) CursorEnd() { + if e.secret { + e.password.CursorEnd() + } else { + e.area.CursorEnd() + } +} +func (e monitorEditor) Height() int { + if !e.ready || e.secret { + return 1 + } + return max(e.area.Height(), 1) +} +func (e monitorEditor) Update(msg tea.Msg) (monitorEditor, tea.Cmd) { + var cmd tea.Cmd + if e.secret { + e.password, cmd = e.password.Update(msg) + } else { + e.area, cmd = e.area.Update(msg) + } + return e, cmd +} +func (e monitorEditor) View(colors palette) string { + if e.secret { + s := e.password.Styles() + s.Focused.Text = colors.label() + s.Focused.Prompt = colors.label().Foreground(colors.primary) + s.Cursor.Color = colors.primary + e.password.SetStyles(s) + return e.password.View() + } + s := e.area.Styles() + s.Focused.Text = colors.label() + s.Focused.Prompt = colors.label().Foreground(colors.primary) + s.Focused.CursorLine = colors.label() + s.Cursor.Color = colors.primary + e.area.SetStyles(s) + return e.area.View() +} diff --git a/internal/ui/monitor_prompt.go b/internal/ui/monitor_prompt.go index 46e3814..aa63298 100644 --- a/internal/ui/monitor_prompt.go +++ b/internal/ui/monitor_prompt.go @@ -6,7 +6,6 @@ import ( "strings" "time" - "charm.land/bubbles/v2/textinput" tea "charm.land/bubbletea/v2" "github.com/charmbracelet/x/ansi" "github.com/merefield/codexometer/internal/codex" @@ -16,7 +15,7 @@ import ( type monitorPromptState struct { session string offer codex.SessionPromptOffer - input textinput.Model + input monitorEditor answers [3]string question int choice int @@ -68,6 +67,11 @@ func (m Model) monitorPromptRows(width, height int) int { return 0 } if m.monitorPromptOffer().Token != "" || m.monitorPrompt.session == m.monitorContextDetail && (m.monitorPrompt.busy || m.monitorPrompt.notice != "") { + p := m.monitorPrompt + if p.input.Focused() && !p.busy { + p.input.configure(width, height) + return p.input.Height() + 2 + } return 3 } return 0 @@ -79,14 +83,16 @@ func (m *Model) focusMonitorPrompt() tea.Cmd { return nil } if m.monitorPrompt.offer.Token != o.Token { - m.monitorPrompt = monitorPromptState{session: m.monitorContextDetail, offer: o, input: textinput.New(), choice: -1} - m.monitorPrompt.input.CharLimit = 4096 + m.monitorPrompt = monitorPromptState{session: m.monitorContextDetail, offer: o, input: newMonitorEditor(), choice: -1} } + m.monitorPrompt.input.setSecret(len(o.Questions) > 0 && o.Questions[m.monitorPrompt.question].Secret) + g := m.dashboardLayout() + m.monitorPrompt.input.configure(g.contentWidth, g.meterHeight) m.monitorPrompt.notice = "" return m.monitorPrompt.input.Focus() } -func (m Model) renderMonitorPrompt(width int, colors palette) string { +func (m Model) renderMonitorPrompt(width, height int, colors palette) string { p := m.monitorPrompt o := m.monitorPromptOffer() header := i18n.Text("FOLLOW-UP") + " // " + o.ThreadID @@ -102,18 +108,8 @@ func (m Model) renderMonitorPrompt(width int, colors palette) string { hint := i18n.Text("Enter: send / next answer • Esc: leave editor • ↑/↓: choices") if p.offer.Token == o.Token && p.input.Focused() && !p.busy { input := p.input - input.SetWidth(max(width-6, 1)) - input.EchoMode = textinput.EchoNormal - if len(o.Questions) > 0 && o.Questions[p.question].Secret { - input.EchoMode = textinput.EchoPassword - } - styles := input.Styles() - styles.Focused.Text = colors.label() - styles.Focused.Prompt = colors.label().Foreground(colors.primary) - styles.Focused.Placeholder = colors.label() - styles.Cursor.Color = colors.primary - input.SetStyles(styles) - line = input.View() + input.configure(width, height) + line = input.View(colors) } if p.busy { line = i18n.Text("Sending…") @@ -121,7 +117,11 @@ func (m Model) renderMonitorPrompt(width int, colors palette) string { if p.notice != "" { hint = p.notice } - return colors.label().Render(ansi.Truncate(header, max(width-4, 1), "…")) + "\n" + ansi.Truncate(line, max(width-4, 1), "") + "\n" + colors.label().Render(ansi.Truncate(hint, max(width-4, 1), "…")) + inputLines := strings.Split(line, "\n") + for i := range inputLines { + inputLines[i] = ansi.Truncate(inputLines[i], max(width-4, 1), "") + } + return colors.label().Render(ansi.Truncate(header, max(width-4, 1), "…")) + "\n" + strings.Join(inputLines, "\n") + "\n" + colors.label().Render(ansi.Truncate(hint, max(width-4, 1), "…")) } func (m Model) updateMonitorPrompt(msg tea.Msg) (Model, tea.Cmd, bool) { @@ -142,6 +142,12 @@ func (m Model) updateMonitorPrompt(msg tea.Msg) (Model, tea.Cmd, bool) { } wasFocused := p.input.Focused() g := m.dashboardLayout() + if size, ok := msg.(tea.WindowSizeMsg); ok { + resized := m + resized.width, resized.height = size.Width, size.Height + g = resized.dashboardLayout() + } + p.input.configure(g.contentWidth, g.meterHeight) if wasFocused && m.monitorPromptRows(g.contentWidth, g.meterHeight) == 0 { p.input.Blur() } @@ -177,9 +183,9 @@ func (m Model) updateMonitorPrompt(msg tea.Msg) (Model, tea.Cmd, bool) { } p.input.SetValue(options[p.choice]) p.input.CursorEnd() + return m, nil, true } } - return m, nil, true } } // Only editor messages are consumed. Telemetry and mouse updates must still @@ -228,6 +234,7 @@ func (m Model) submitMonitorPrompt() (Model, tea.Cmd, bool) { p.question++ p.choice = -1 p.input.Reset() + p.input.setSecret(p.offer.Questions[p.question].Secret) p.notice = "" return m, nil, true } diff --git a/internal/ui/monitor_prompt_test.go b/internal/ui/monitor_prompt_test.go index 52ed88f..41f5454 100644 --- a/internal/ui/monitor_prompt_test.go +++ b/internal/ui/monitor_prompt_test.go @@ -54,13 +54,13 @@ func TestMonitorPromptEnterAndHotkeyIsolation(t *testing.T) { if !m.monitorPrompt.input.Focused() { t.Fatal("Enter did not focus") } - for _, text := range []string{"Q", "s", "t", "h", "i", "X", "Hello 世界"} { + for _, text := range []string{"Q", "s", "t", "h", "i", "X", "1", "C", "Hello 世界"} { m, _ = promptKey(m, []rune(text)[0], text) } if m.monitorContextHidden || m.monitorContextDetail != "root-one" { t.Fatal("typing triggered dashboard action") } - want := "QsthiXHello 世界" + want := "QsthiX1CHello 世界" if m.monitorPrompt.input.Value() != want { t.Fatalf("case/input lost: %q", m.monitorPrompt.input.Value()) } @@ -189,3 +189,73 @@ func TestMonitorPromptSourceAndChangedRequest(t *testing.T) { t.Fatal("prompt notice overlaps approval footer") } } + +func TestMonitorPromptWrapsAndGrowsUpward(t *testing.T) { + for _, size := range [][2]int{{40, 16}, {80, 24}, {120, 40}, {180, 50}} { + m, _ := promptTestModel() + m.width, m.height = size[0], size[1] + m.focusMonitorPrompt() + g := m.dashboardLayout() + _, _, initialY := monitorContextBodyLayout(g.meterHeight, m.monitorPromptRows(g.contentWidth, g.meterHeight)) + value := strings.Repeat("Hello 世界 ", 60) + m.monitorPrompt.input.SetValue(value) + rows := m.monitorPromptRows(g.contentWidth, g.meterHeight) + _, _, y := monitorContextBodyLayout(g.meterHeight, rows) + if g.meterHeight > 9 && (rows <= 3 || y >= initialY) { + t.Fatalf("editor did not grow upward %v: rows=%d y=%d initial=%d", size, rows, y, initialY) + } + if rows > max(g.meterHeight-8, 1)+2 { + t.Fatal("editor exceeded available space") + } + out := m.render() + if lipgloss.Width(out) > m.width || lipgloss.Height(out) > m.height { + t.Fatalf("overflow %v\n%s", size, ansi.Strip(out)) + } + for row := y + 1; row < y+rows-1; row++ { + for x := 4; x < g.contentWidth; x++ { + if got := m.monitorContextAt(x, g.meterY+row); got != "prompt" { + t.Fatalf("wrapped input miss %v %d,%d: %s", size, x, row, got) + } + } + } + if m.monitorContextAt(4, g.meterY+y+rows-1) == "prompt" { + t.Fatal("hint line became editable hit target") + } + if m.monitorPrompt.input.Value() != value { + t.Fatal("wrapping altered the submitted text") + } + m.monitorPrompt.input.SetValue("short") + if m.monitorPromptRows(g.contentWidth, g.meterHeight) != 3 { + t.Fatal("editor failed to shrink") + } + } +} + +func TestMonitorPromptResizeAndWrappedSubmission(t *testing.T) { + m, c := promptTestModel() + m.focusMonitorPrompt() + text := strings.Repeat("words ", 70) + "\nlast line" + n, _ := m.Update(tea.PasteMsg{Content: text}) + m = n.(Model) + if m.monitorPrompt.input.Value() != text || c.answers != nil { + t.Fatal("multiline paste altered or submitted") + } + for _, width := range []int{40, 180, 60, 120} { + n, _ = m.Update(tea.WindowSizeMsg{Width: width, Height: 30}) + m = n.(Model) + if m.monitorPrompt.input.Value() != text { + t.Fatal("resize lost input") + } + if out := m.render(); lipgloss.Width(out) > width || lipgloss.Height(out) > 30 { + t.Fatal("resized editor overflow") + } + } + m, cmd := promptKey(m, tea.KeyEnter, "") + if cmd == nil { + t.Fatal("Enter did not submit wrapped draft") + } + cmd() + if len(c.answers) != 1 || c.answers[0] != text { + t.Fatal("submitted visual wraps instead of original text") + } +} diff --git a/main.go b/main.go index 3d11434..03ef6fd 100644 --- a/main.go +++ b/main.go @@ -119,6 +119,7 @@ func (d *demoFetcher) FetchTokenUsage(context.Context) (codex.LiveUsageSnapshot, alphaTokens := d.lifetimeTokens * 3 / 5 attention := codex.SessionAttentionApproval preview := codex.SessionContext{Kind: codex.SessionContextApproval, Text: "DEMO ONLY — no command will run.\nAllow pushing the documentation update?\nCommand: git push origin main\nDirectory: /projects/alpha", ThreadID: "019d-demo-a1b2c", Source: "DEMO", At: time.Now().Add(-time.Minute), ApprovalToken: "demo-command"} + preview.CommandDetails = codex.ApprovalCommandDetails{Justification: "DEMO ONLY — no command will run.\nAllow pushing the documentation update?", Command: "git push origin main", Directory: "/projects/alpha"} preview.ApprovalOptions = [8]codex.ApprovalOption{{Kind: "accept", Value: "accept", Wire: `"accept"`}, {Kind: "acceptForSession", Value: "acceptForSession", Wire: `"acceptForSession"`}, {Kind: "cancel", Value: "cancel", Wire: `"cancel"`}} preview.ApprovalDecisions = "accept, acceptForSession, cancel" if d.approvalDecision != "" { From 8194baaad5f375d4dde196754c730d7f025ae1e9 Mon Sep 17 00:00:00 2001 From: merefield Date: Sun, 6 Sep 2026 16:45:21 +0100 Subject: [PATCH 5/9] FIX: preserve detail footer shortcuts and shorten send notice --- internal/i18n/locales/de.json | 2 +- internal/i18n/locales/en-GB.json | 2 +- internal/i18n/locales/es.json | 2 +- internal/i18n/locales/fr.json | 2 +- internal/i18n/locales/it.json | 2 +- internal/i18n/locales/ja.json | 2 +- internal/i18n/locales/nl.json | 2 +- internal/i18n/locales/ru.json | 2 +- internal/i18n/locales/zh-Hans.json | 2 +- internal/ui/monitor_context.go | 4 +++ internal/ui/monitor_context_test.go | 38 +++++++++++++++++++++++++++++ internal/ui/monitor_prompt.go | 2 +- internal/ui/monitor_prompt_test.go | 4 +-- 13 files changed, 54 insertions(+), 12 deletions(-) diff --git a/internal/i18n/locales/de.json b/internal/i18n/locales/de.json index 39af791..b3613b6 100644 --- a/internal/i18n/locales/de.json +++ b/internal/i18n/locales/de.json @@ -8,7 +8,7 @@ "[ Click here or press Enter to write ]": "[ Hier klicken oder Enter zum Schreiben ]", "Enter: send / next answer • Esc: leave editor • ↑/↓: choices": "Enter: senden / weiter • Esc: Editor verlassen • ↑/↓: Auswahl", "Sending…": "Wird gesendet…", - "Text sent; check Codex for the outcome.": "Text gesendet; Ergebnis in Codex prüfen.", + "Text sent…": "Text gesendet…", "Send unconfirmed; check Codex before retrying.": "Senden unbestätigt; vor erneutem Versuch Codex prüfen.", "Prompt changed; review the session before replying.": "Eingabe geändert; Sitzung vor dem Antworten prüfen.", "Choose an offered answer with ↑/↓.": "Mit ↑/↓ eine angebotene Antwort wählen.", diff --git a/internal/i18n/locales/en-GB.json b/internal/i18n/locales/en-GB.json index 284fadb..84142c2 100644 --- a/internal/i18n/locales/en-GB.json +++ b/internal/i18n/locales/en-GB.json @@ -8,7 +8,7 @@ "[ Click here or press Enter to write ]": "[ Click here or press Enter to write ]", "Enter: send / next answer • Esc: leave editor • ↑/↓: choices": "Enter: send / next answer • Esc: leave editor • ↑/↓: choices", "Sending…": "Sending…", - "Text sent; check Codex for the outcome.": "Text sent; check Codex for the outcome.", + "Text sent…": "Text sent…", "Send unconfirmed; check Codex before retrying.": "Send unconfirmed; check Codex before retrying.", "Prompt changed; review the session before replying.": "Prompt changed; review the session before replying.", "Choose an offered answer with ↑/↓.": "Choose an offered answer with ↑/↓.", diff --git a/internal/i18n/locales/es.json b/internal/i18n/locales/es.json index 162581a..50d6b4b 100644 --- a/internal/i18n/locales/es.json +++ b/internal/i18n/locales/es.json @@ -8,7 +8,7 @@ "[ Click here or press Enter to write ]": "[ Haz clic aquí o pulsa Intro para escribir ]", "Enter: send / next answer • Esc: leave editor • ↑/↓: choices": "Intro: enviar / siguiente • Esc: salir • ↑/↓: opciones", "Sending…": "Enviando…", - "Text sent; check Codex for the outcome.": "Texto enviado; comprueba el resultado en Codex.", + "Text sent…": "Texto enviado…", "Send unconfirmed; check Codex before retrying.": "Envío sin confirmar; comprueba Codex antes de reintentar.", "Prompt changed; review the session before replying.": "Solicitud cambiada; revisa la sesión antes de responder.", "Choose an offered answer with ↑/↓.": "Elige una respuesta ofrecida con ↑/↓.", diff --git a/internal/i18n/locales/fr.json b/internal/i18n/locales/fr.json index 8f2c474..5cab749 100644 --- a/internal/i18n/locales/fr.json +++ b/internal/i18n/locales/fr.json @@ -8,7 +8,7 @@ "[ Click here or press Enter to write ]": "[ Cliquez ici ou Entrée pour écrire ]", "Enter: send / next answer • Esc: leave editor • ↑/↓: choices": "Entrée : envoyer / suivant • Échap : quitter • ↑/↓ : choix", "Sending…": "Envoi…", - "Text sent; check Codex for the outcome.": "Texte envoyé ; consultez le résultat dans Codex.", + "Text sent…": "Texte envoyé…", "Send unconfirmed; check Codex before retrying.": "Envoi non confirmé ; vérifiez Codex avant de réessayer.", "Prompt changed; review the session before replying.": "Invite modifiée ; vérifiez la session avant de répondre.", "Choose an offered answer with ↑/↓.": "Choisissez une réponse proposée avec ↑/↓.", diff --git a/internal/i18n/locales/it.json b/internal/i18n/locales/it.json index f053885..cfeba98 100644 --- a/internal/i18n/locales/it.json +++ b/internal/i18n/locales/it.json @@ -8,7 +8,7 @@ "[ Click here or press Enter to write ]": "[ Clicca qui o premi Invio per scrivere ]", "Enter: send / next answer • Esc: leave editor • ↑/↓: choices": "Invio: invia / avanti • Esc: esci • ↑/↓: opzioni", "Sending…": "Invio…", - "Text sent; check Codex for the outcome.": "Testo inviato; controlla l’esito in Codex.", + "Text sent…": "Testo inviato…", "Send unconfirmed; check Codex before retrying.": "Invio non confermato; controlla Codex prima di riprovare.", "Prompt changed; review the session before replying.": "Richiesta cambiata; controlla la sessione prima di rispondere.", "Choose an offered answer with ↑/↓.": "Scegli una risposta proposta con ↑/↓.", diff --git a/internal/i18n/locales/ja.json b/internal/i18n/locales/ja.json index ea68574..b9c4282 100644 --- a/internal/i18n/locales/ja.json +++ b/internal/i18n/locales/ja.json @@ -8,7 +8,7 @@ "[ Click here or press Enter to write ]": "[ クリックまたは Enter で入力 ]", "Enter: send / next answer • Esc: leave editor • ↑/↓: choices": "Enter: 送信/次の回答 • Esc: 入力終了 • ↑/↓: 選択", "Sending…": "送信中…", - "Text sent; check Codex for the outcome.": "テキストを送信しました。Codex で結果を確認してください。", + "Text sent…": "テキストを送信しました…", "Send unconfirmed; check Codex before retrying.": "送信未確認。再試行前に Codex を確認してください。", "Prompt changed; review the session before replying.": "プロンプトが変わりました。回答前にセッションを確認してください。", "Choose an offered answer with ↑/↓.": "↑/↓ で提示された回答を選んでください。", diff --git a/internal/i18n/locales/nl.json b/internal/i18n/locales/nl.json index d3f2e00..7dfb51d 100644 --- a/internal/i18n/locales/nl.json +++ b/internal/i18n/locales/nl.json @@ -8,7 +8,7 @@ "[ Click here or press Enter to write ]": "[ Klik hier of druk op Enter om te typen ]", "Enter: send / next answer • Esc: leave editor • ↑/↓: choices": "Enter: verzenden / volgende • Esc: editor verlaten • ↑/↓: keuzes", "Sending…": "Verzenden…", - "Text sent; check Codex for the outcome.": "Tekst verzonden; bekijk het resultaat in Codex.", + "Text sent…": "Tekst verzonden…", "Send unconfirmed; check Codex before retrying.": "Verzending onbevestigd; controleer Codex vóór opnieuw proberen.", "Prompt changed; review the session before replying.": "Prompt gewijzigd; controleer de sessie vóór antwoorden.", "Choose an offered answer with ↑/↓.": "Kies een aangeboden antwoord met ↑/↓.", diff --git a/internal/i18n/locales/ru.json b/internal/i18n/locales/ru.json index cfcbcf9..26f5b06 100644 --- a/internal/i18n/locales/ru.json +++ b/internal/i18n/locales/ru.json @@ -8,7 +8,7 @@ "[ Click here or press Enter to write ]": "[ Нажмите здесь или Enter для ввода ]", "Enter: send / next answer • Esc: leave editor • ↑/↓: choices": "Enter: отправить / далее • Esc: выйти • ↑/↓: варианты", "Sending…": "Отправка…", - "Text sent; check Codex for the outcome.": "Текст отправлен; проверьте результат в Codex.", + "Text sent…": "Текст отправлен…", "Send unconfirmed; check Codex before retrying.": "Отправка не подтверждена; проверьте Codex перед повтором.", "Prompt changed; review the session before replying.": "Запрос изменился; проверьте сессию перед ответом.", "Choose an offered answer with ↑/↓.": "Выберите предложенный ответ с помощью ↑/↓.", diff --git a/internal/i18n/locales/zh-Hans.json b/internal/i18n/locales/zh-Hans.json index 2ba8f12..362f1ac 100644 --- a/internal/i18n/locales/zh-Hans.json +++ b/internal/i18n/locales/zh-Hans.json @@ -8,7 +8,7 @@ "[ Click here or press Enter to write ]": "[ 点击此处或按 Enter 输入 ]", "Enter: send / next answer • Esc: leave editor • ↑/↓: choices": "Enter:发送/下一题 • Esc:退出编辑 • ↑/↓:选项", "Sending…": "正在发送…", - "Text sent; check Codex for the outcome.": "文本已发送;请在 Codex 中查看结果。", + "Text sent…": "文本已发送…", "Send unconfirmed; check Codex before retrying.": "发送未确认;重试前请检查 Codex。", "Prompt changed; review the session before replying.": "提示已更改;回复前请检查会话。", "Choose an offered answer with ↑/↓.": "使用 ↑/↓ 选择提供的答案。", diff --git a/internal/ui/monitor_context.go b/internal/ui/monitor_context.go index 394efd0..33d0a0d 100644 --- a/internal/ui/monitor_context.go +++ b/internal/ui/monitor_context.go @@ -223,6 +223,10 @@ func (m Model) updateMonitorContextKey(key string) (Model, tea.Cmd, bool) { } if m.monitorContextDetail != "" && !m.monitorContextHidden { switch key { + case "t", "r": + // These controls remain visible in the global footer. Keep detail + // open and let the standard handler apply the action and flash. + return m, nil, false case "enter": g := m.dashboardLayout() if m.monitorPromptRows(g.contentWidth, g.meterHeight) > 0 && m.monitorPromptOffer().Token != "" { diff --git a/internal/ui/monitor_context_test.go b/internal/ui/monitor_context_test.go index 24123ba..b131461 100644 --- a/internal/ui/monitor_context_test.go +++ b/internal/ui/monitor_context_test.go @@ -21,6 +21,44 @@ func contextTestModel() Model { return m } +func TestMonitorDetailKeepsVisibleGlobalShortcuts(t *testing.T) { + for _, tc := range []struct { + key rune + button footerButtonID + }{{'t', footerButtonTheme}, {'r', footerButtonRefresh}, {'q', footerButtonQuit}} { + m := approvalTestModel() + updated, cmd := m.Update(key(tc.key)) + next := updated.(Model) + if cmd == nil || next.flashedButton != tc.button { + t.Fatalf("footer shortcut %c swallowed", tc.key) + } + if tc.key != 'q' && next.monitorContextDetail != m.monitorContextDetail { + t.Fatal("global action closed detail") + } + if tc.key == 't' && next.theme != m.theme.next() { + t.Fatal("theme did not change") + } + if tc.key == 'r' && !next.loading { + t.Fatal("refresh not requested") + } + } + for _, code := range []rune{tea.KeyTab} { + m := approvalTestModel() + updated, _ := m.Update(tea.KeyPressMsg{Code: code}) + if next := updated.(Model); next.meterView == viewMonitor || next.monitorContextDetail != "" { + t.Fatal("Tab failed to leave detail") + } + } + for _, k := range []rune{'s', 'p'} { + m := approvalTestModel() + updated, cmd := m.Update(key(k)) + next := updated.(Model) + if cmd != nil || next.monitorState != m.monitorState { + t.Fatal("hidden Monitor controls activated from detail") + } + } +} + func TestApprovalDetailSeparatesJustificationFromCommand(t *testing.T) { m := approvalTestModel() for _, text := range []string{ diff --git a/internal/ui/monitor_prompt.go b/internal/ui/monitor_prompt.go index aa63298..e7d10ad 100644 --- a/internal/ui/monitor_prompt.go +++ b/internal/ui/monitor_prompt.go @@ -132,7 +132,7 @@ func (m Model) updateMonitorPrompt(msg tea.Msg) (Model, tea.Cmd, bool) { p.input.Reset() p.input.Blur() p.answers = [3]string{} - p.notice = i18n.Text("Text sent; check Codex for the outcome.") + p.notice = i18n.Text("Text sent…") if result.err != nil { p.notice = i18n.Text("Send unconfirmed; check Codex before retrying.") } diff --git a/internal/ui/monitor_prompt_test.go b/internal/ui/monitor_prompt_test.go index 41f5454..10929c2 100644 --- a/internal/ui/monitor_prompt_test.go +++ b/internal/ui/monitor_prompt_test.go @@ -54,13 +54,13 @@ func TestMonitorPromptEnterAndHotkeyIsolation(t *testing.T) { if !m.monitorPrompt.input.Focused() { t.Fatal("Enter did not focus") } - for _, text := range []string{"Q", "s", "t", "h", "i", "X", "1", "C", "Hello 世界"} { + for _, text := range []string{"Q", "s", "t", "r", "h", "i", "X", "1", "C", "Hello 世界"} { m, _ = promptKey(m, []rune(text)[0], text) } if m.monitorContextHidden || m.monitorContextDetail != "root-one" { t.Fatal("typing triggered dashboard action") } - want := "QsthiX1CHello 世界" + want := "QstrhiX1CHello 世界" if m.monitorPrompt.input.Value() != want { t.Fatalf("case/input lost: %q", m.monitorPrompt.input.Value()) } From fc4a70cb4f7b65d4662ab1ee8de68d1efced272e Mon Sep 17 00:00:00 2001 From: merefield Date: Sun, 6 Sep 2026 16:47:18 +0100 Subject: [PATCH 6/9] FIX: harden Monitor approval handling and informational state --- internal/codex/session_approval_test.go | 17 +++++++++++ internal/codex/session_context_daemon.go | 5 ++++ internal/i18n/locales/de.json | 5 ++-- internal/i18n/locales/en-GB.json | 5 ++-- internal/i18n/locales/es.json | 5 ++-- internal/i18n/locales/fr.json | 5 ++-- internal/i18n/locales/it.json | 5 ++-- internal/i18n/locales/ja.json | 5 ++-- internal/i18n/locales/nl.json | 5 ++-- internal/i18n/locales/ru.json | 5 ++-- internal/i18n/locales/zh-Hans.json | 5 ++-- internal/ui/model.go | 10 ++++--- internal/ui/monitor.go | 12 ++++++-- internal/ui/monitor_approval.go | 11 +++++++- internal/ui/monitor_approval_test.go | 36 ++++++++++++++++++++++++ internal/ui/monitor_context.go | 4 +-- internal/ui/monitor_context_modes.go | 2 +- internal/ui/monitor_context_test.go | 32 +++++++++++++++++++++ internal/ui/monitor_detail.go | 2 +- internal/ui/monitor_prompt.go | 2 +- 20 files changed, 148 insertions(+), 30 deletions(-) diff --git a/internal/codex/session_approval_test.go b/internal/codex/session_approval_test.go index 6496ef5..7d9d4b4 100644 --- a/internal/codex/session_approval_test.go +++ b/internal/codex/session_approval_test.go @@ -32,6 +32,10 @@ func TestApprovalCompleteCommandAndFailClosed(t *testing.T) { t.Fatal("structured fields did not preserve source values", c.CommandDetails) } for name, fields := range map[string]map[string]any{ + "stdin action": {"kind": "writeStdin"}, + "future action": {"kind": "unknownAction"}, + "null action": {"kind": nil}, + "malformed action": {"kind": 42}, "wrong turn": {"turnId": "other"}, "wrong item": {"itemId": "other"}, "truncated": {"command": strings.Repeat("x", 5000)}, @@ -55,6 +59,19 @@ func TestApprovalCompleteCommandAndFailClosed(t *testing.T) { } } +func TestApprovalKindExplicitAndLegacyCommand(t *testing.T) { + for _, fields := range []map[string]any{nil, {"kind": "command"}} { + _, c := approvalFixture(t, fields) + if c.ApprovalToken == "" || c.CommandDetails.Command != "git push" { + t.Fatal("valid command blocked", c) + } + } + _, c := approvalFixture(t, map[string]any{"kind": "writeStdin", "command": "git push", "cwd": "/work"}) + if c.ApprovalToken != "" || c.ApprovalBlocked != "approval-kind" { + t.Fatal("stdin treated as command", c) + } +} + func TestApprovalStructuredJustificationIsBounded(t *testing.T) { _, c := approvalFixture(t, map[string]any{"reason": strings.Repeat(" ", 10000) + "Explanation\nCommand: not the real command"}) if c.ApprovalToken == "" || c.CommandDetails.Command != "git push" || c.CommandDetails.Justification != "Explanation\nCommand: not the real command" { diff --git a/internal/codex/session_context_daemon.go b/internal/codex/session_context_daemon.go index 225cbf5..fd376bf 100644 --- a/internal/codex/session_context_daemon.go +++ b/internal/codex/session_context_daemon.go @@ -37,6 +37,7 @@ func daemonContextEvent(states map[string]*daemonContextState, method string, id RequestID json.RawMessage `json:"requestId"` Reason string `json:"reason"` Command json.RawMessage `json:"command"` + Kind json.RawMessage `json:"kind"` Questions []contextQuestion `json:"questions"` IsBlocking *bool `json:"isBlocking"` Item struct { @@ -74,6 +75,8 @@ func daemonContextEvent(states map[string]*daemonContextState, method string, id delete(state.requests, string(p.RequestID)) return case "item/commandExecution/requestApproval": + kind := "command" // legacy servers omit the discriminator + kindValid := len(p.Kind) == 0 || json.Unmarshal(p.Kind, &kind) == nil && kind == "command" && string(p.Kind) != "null" var command string commandValid := len(p.Command) == 0 || string(p.Command) == "null" || json.Unmarshal(p.Command, &command) == nil previous := state.commands[p.TurnID+"/"+p.ItemID] @@ -102,6 +105,8 @@ func daemonContextEvent(states map[string]*daemonContextState, method string, id // Fail closed if the display loses ANY command/request content, or if // this is a broader grant rather than an ordinary command decision. switch { + case !kindValid: + c.ApprovalBlocked = "approval-kind" case special(p.Network): c.ApprovalBlocked = "network" case special(p.Permissions): diff --git a/internal/i18n/locales/de.json b/internal/i18n/locales/de.json index b3613b6..ad5a47e 100644 --- a/internal/i18n/locales/de.json +++ b/internal/i18n/locales/de.json @@ -1,4 +1,5 @@ { + "This approval action must be handled in Codex.": "Diese Genehmigungsaktion muss in Codex bearbeitet werden.", "JUSTIFICATION": "BEGRÜNDUNG", "COMMAND": "BEFEHL", "WORKING DIRECTORY": "ARBEITSVERZEICHNIS", @@ -8,7 +9,7 @@ "[ Click here or press Enter to write ]": "[ Hier klicken oder Enter zum Schreiben ]", "Enter: send / next answer • Esc: leave editor • ↑/↓: choices": "Enter: senden / weiter • Esc: Editor verlassen • ↑/↓: Auswahl", "Sending…": "Wird gesendet…", - "Text sent…": "Text gesendet…", + "Text sent ...": "Text gesendet ...", "Send unconfirmed; check Codex before retrying.": "Senden unbestätigt; vor erneutem Versuch Codex prüfen.", "Prompt changed; review the session before replying.": "Eingabe geändert; Sitzung vor dem Antworten prüfen.", "Choose an offered answer with ↑/↓.": "Mit ↑/↓ eine angebotene Antwort wählen.", @@ -39,7 +40,7 @@ "[ CONFIRM APPROVAL ]": "[ GENEHMIGUNG BESTÄTIGEN ]", "[ DECLINE ]": "[ ABLEHNEN ]", "Sending decision…": "Entscheidung wird gesendet…", - "Decision sent…": "Entscheidung gesendet…", + "Decision sent ...": "Entscheidung gesendet ...", "Decision unconfirmed; check Codex. Do not retry here.": "Entscheidung unbestätigt; Codex prüfen. Hier nicht erneut versuchen.", "LAST REPLY": "LETZTE ANTWORT", "QUESTION": "FRAGE", diff --git a/internal/i18n/locales/en-GB.json b/internal/i18n/locales/en-GB.json index 84142c2..2dd4992 100644 --- a/internal/i18n/locales/en-GB.json +++ b/internal/i18n/locales/en-GB.json @@ -1,4 +1,5 @@ { + "This approval action must be handled in Codex.": "This approval action must be handled in Codex.", "JUSTIFICATION": "JUSTIFICATION", "COMMAND": "COMMAND", "WORKING DIRECTORY": "WORKING DIRECTORY", @@ -8,7 +9,7 @@ "[ Click here or press Enter to write ]": "[ Click here or press Enter to write ]", "Enter: send / next answer • Esc: leave editor • ↑/↓: choices": "Enter: send / next answer • Esc: leave editor • ↑/↓: choices", "Sending…": "Sending…", - "Text sent…": "Text sent…", + "Text sent ...": "Text sent ...", "Send unconfirmed; check Codex before retrying.": "Send unconfirmed; check Codex before retrying.", "Prompt changed; review the session before replying.": "Prompt changed; review the session before replying.", "Choose an offered answer with ↑/↓.": "Choose an offered answer with ↑/↓.", @@ -39,7 +40,7 @@ "[ CONFIRM APPROVAL ]": "[ CONFIRM APPROVAL ]", "[ DECLINE ]": "[ DECLINE ]", "Sending decision…": "Sending decision…", - "Decision sent…": "Decision sent…", + "Decision sent ...": "Decision sent ...", "Decision unconfirmed; check Codex. Do not retry here.": "Decision unconfirmed; check Codex. Do not retry here.", "LAST REPLY": "LAST REPLY", "QUESTION": "QUESTION", diff --git a/internal/i18n/locales/es.json b/internal/i18n/locales/es.json index 50d6b4b..d9e66a5 100644 --- a/internal/i18n/locales/es.json +++ b/internal/i18n/locales/es.json @@ -1,4 +1,5 @@ { + "This approval action must be handled in Codex.": "Esta autorización debe gestionarse en Codex.", "JUSTIFICATION": "JUSTIFICACIÓN", "COMMAND": "COMANDO", "WORKING DIRECTORY": "DIRECTORIO DE TRABAJO", @@ -8,7 +9,7 @@ "[ Click here or press Enter to write ]": "[ Haz clic aquí o pulsa Intro para escribir ]", "Enter: send / next answer • Esc: leave editor • ↑/↓: choices": "Intro: enviar / siguiente • Esc: salir • ↑/↓: opciones", "Sending…": "Enviando…", - "Text sent…": "Texto enviado…", + "Text sent ...": "Texto enviado ...", "Send unconfirmed; check Codex before retrying.": "Envío sin confirmar; comprueba Codex antes de reintentar.", "Prompt changed; review the session before replying.": "Solicitud cambiada; revisa la sesión antes de responder.", "Choose an offered answer with ↑/↓.": "Elige una respuesta ofrecida con ↑/↓.", @@ -39,7 +40,7 @@ "[ CONFIRM APPROVAL ]": "[ CONFIRMAR APROBACIÓN ]", "[ DECLINE ]": "[ RECHAZAR ]", "Sending decision…": "Enviando decisión…", - "Decision sent…": "Decisión enviada…", + "Decision sent ...": "Decisión enviada ...", "Decision unconfirmed; check Codex. Do not retry here.": "Decisión sin confirmar; revisa Codex. No lo reintentes aquí.", "LAST REPLY": "ÚLTIMA RESPUESTA", "QUESTION": "PREGUNTA", diff --git a/internal/i18n/locales/fr.json b/internal/i18n/locales/fr.json index 5cab749..ea95ebf 100644 --- a/internal/i18n/locales/fr.json +++ b/internal/i18n/locales/fr.json @@ -1,4 +1,5 @@ { + "This approval action must be handled in Codex.": "Cette autorisation doit être traitée dans Codex.", "JUSTIFICATION": "JUSTIFICATION", "COMMAND": "COMMANDE", "WORKING DIRECTORY": "RÉPERTOIRE DE TRAVAIL", @@ -8,7 +9,7 @@ "[ Click here or press Enter to write ]": "[ Cliquez ici ou Entrée pour écrire ]", "Enter: send / next answer • Esc: leave editor • ↑/↓: choices": "Entrée : envoyer / suivant • Échap : quitter • ↑/↓ : choix", "Sending…": "Envoi…", - "Text sent…": "Texte envoyé…", + "Text sent ...": "Texte envoyé ...", "Send unconfirmed; check Codex before retrying.": "Envoi non confirmé ; vérifiez Codex avant de réessayer.", "Prompt changed; review the session before replying.": "Invite modifiée ; vérifiez la session avant de répondre.", "Choose an offered answer with ↑/↓.": "Choisissez une réponse proposée avec ↑/↓.", @@ -39,7 +40,7 @@ "[ CONFIRM APPROVAL ]": "[ CONFIRMER L’AUTORISATION ]", "[ DECLINE ]": "[ REFUSER ]", "Sending decision…": "Envoi de la décision…", - "Decision sent…": "Décision envoyée…", + "Decision sent ...": "Décision envoyée ...", "Decision unconfirmed; check Codex. Do not retry here.": "Décision non confirmée ; vérifiez Codex. Ne réessayez pas ici.", "LAST REPLY": "DERNIÈRE RÉPONSE", "QUESTION": "QUESTION", diff --git a/internal/i18n/locales/it.json b/internal/i18n/locales/it.json index cfeba98..f25f37b 100644 --- a/internal/i18n/locales/it.json +++ b/internal/i18n/locales/it.json @@ -1,4 +1,5 @@ { + "This approval action must be handled in Codex.": "Questa autorizzazione deve essere gestita in Codex.", "JUSTIFICATION": "MOTIVAZIONE", "COMMAND": "COMANDO", "WORKING DIRECTORY": "CARTELLA DI LAVORO", @@ -8,7 +9,7 @@ "[ Click here or press Enter to write ]": "[ Clicca qui o premi Invio per scrivere ]", "Enter: send / next answer • Esc: leave editor • ↑/↓: choices": "Invio: invia / avanti • Esc: esci • ↑/↓: opzioni", "Sending…": "Invio…", - "Text sent…": "Testo inviato…", + "Text sent ...": "Testo inviato ...", "Send unconfirmed; check Codex before retrying.": "Invio non confermato; controlla Codex prima di riprovare.", "Prompt changed; review the session before replying.": "Richiesta cambiata; controlla la sessione prima di rispondere.", "Choose an offered answer with ↑/↓.": "Scegli una risposta proposta con ↑/↓.", @@ -39,7 +40,7 @@ "[ CONFIRM APPROVAL ]": "[ CONFERMA APPROVAZIONE ]", "[ DECLINE ]": "[ RIFIUTA ]", "Sending decision…": "Invio della decisione…", - "Decision sent…": "Decisione inviata…", + "Decision sent ...": "Decisione inviata ...", "Decision unconfirmed; check Codex. Do not retry here.": "Decisione non confermata; controlla Codex. Non riprovare qui.", "LAST REPLY": "ULTIMA RISPOSTA", "QUESTION": "DOMANDA", diff --git a/internal/i18n/locales/ja.json b/internal/i18n/locales/ja.json index b9c4282..bbab2b6 100644 --- a/internal/i18n/locales/ja.json +++ b/internal/i18n/locales/ja.json @@ -1,4 +1,5 @@ { + "This approval action must be handled in Codex.": "この承認操作は Codex で行ってください。", "JUSTIFICATION": "理由", "COMMAND": "コマンド", "WORKING DIRECTORY": "作業ディレクトリ", @@ -8,7 +9,7 @@ "[ Click here or press Enter to write ]": "[ クリックまたは Enter で入力 ]", "Enter: send / next answer • Esc: leave editor • ↑/↓: choices": "Enter: 送信/次の回答 • Esc: 入力終了 • ↑/↓: 選択", "Sending…": "送信中…", - "Text sent…": "テキストを送信しました…", + "Text sent ...": "テキストを送信しました ...", "Send unconfirmed; check Codex before retrying.": "送信未確認。再試行前に Codex を確認してください。", "Prompt changed; review the session before replying.": "プロンプトが変わりました。回答前にセッションを確認してください。", "Choose an offered answer with ↑/↓.": "↑/↓ で提示された回答を選んでください。", @@ -39,7 +40,7 @@ "[ CONFIRM APPROVAL ]": "[ 許可を確定 ]", "[ DECLINE ]": "[ 拒否 ]", "Sending decision…": "決定を送信中…", - "Decision sent…": "決定を送信しました…", + "Decision sent ...": "決定を送信しました ...", "Decision unconfirmed; check Codex. Do not retry here.": "決定を確認できません。Codex を確認し、ここでは再試行しないでください。", "LAST REPLY": "直近の返信", "QUESTION": "質問", diff --git a/internal/i18n/locales/nl.json b/internal/i18n/locales/nl.json index 7dfb51d..cacd93b 100644 --- a/internal/i18n/locales/nl.json +++ b/internal/i18n/locales/nl.json @@ -1,4 +1,5 @@ { + "This approval action must be handled in Codex.": "Deze goedkeuringsactie moet in Codex worden afgehandeld.", "JUSTIFICATION": "TOELICHTING", "COMMAND": "OPDRACHT", "WORKING DIRECTORY": "WERKMAP", @@ -8,7 +9,7 @@ "[ Click here or press Enter to write ]": "[ Klik hier of druk op Enter om te typen ]", "Enter: send / next answer • Esc: leave editor • ↑/↓: choices": "Enter: verzenden / volgende • Esc: editor verlaten • ↑/↓: keuzes", "Sending…": "Verzenden…", - "Text sent…": "Tekst verzonden…", + "Text sent ...": "Tekst verzonden ...", "Send unconfirmed; check Codex before retrying.": "Verzending onbevestigd; controleer Codex vóór opnieuw proberen.", "Prompt changed; review the session before replying.": "Prompt gewijzigd; controleer de sessie vóór antwoorden.", "Choose an offered answer with ↑/↓.": "Kies een aangeboden antwoord met ↑/↓.", @@ -39,7 +40,7 @@ "[ CONFIRM APPROVAL ]": "[ GOEDKEURING BEVESTIGEN ]", "[ DECLINE ]": "[ WEIGEREN ]", "Sending decision…": "Beslissing verzenden…", - "Decision sent…": "Beslissing verzonden…", + "Decision sent ...": "Beslissing verzonden ...", "Decision unconfirmed; check Codex. Do not retry here.": "Beslissing niet bevestigd; controleer Codex. Probeer hier niet opnieuw.", "LAST REPLY": "LAATSTE ANTWOORD", "QUESTION": "VRAAG", diff --git a/internal/i18n/locales/ru.json b/internal/i18n/locales/ru.json index 26f5b06..b5a2d44 100644 --- a/internal/i18n/locales/ru.json +++ b/internal/i18n/locales/ru.json @@ -1,4 +1,5 @@ { + "This approval action must be handled in Codex.": "Это разрешение нужно обработать в Codex.", "JUSTIFICATION": "ОБОСНОВАНИЕ", "COMMAND": "КОМАНДА", "WORKING DIRECTORY": "РАБОЧИЙ КАТАЛОГ", @@ -8,7 +9,7 @@ "[ Click here or press Enter to write ]": "[ Нажмите здесь или Enter для ввода ]", "Enter: send / next answer • Esc: leave editor • ↑/↓: choices": "Enter: отправить / далее • Esc: выйти • ↑/↓: варианты", "Sending…": "Отправка…", - "Text sent…": "Текст отправлен…", + "Text sent ...": "Текст отправлен ...", "Send unconfirmed; check Codex before retrying.": "Отправка не подтверждена; проверьте Codex перед повтором.", "Prompt changed; review the session before replying.": "Запрос изменился; проверьте сессию перед ответом.", "Choose an offered answer with ↑/↓.": "Выберите предложенный ответ с помощью ↑/↓.", @@ -39,7 +40,7 @@ "[ CONFIRM APPROVAL ]": "[ ПОДТВЕРДИТЬ РАЗРЕШЕНИЕ ]", "[ DECLINE ]": "[ ОТКЛОНИТЬ ]", "Sending decision…": "Отправка решения…", - "Decision sent…": "Решение отправлено…", + "Decision sent ...": "Решение отправлено ...", "Decision unconfirmed; check Codex. Do not retry here.": "Решение не подтверждено; проверьте Codex. Не повторяйте здесь.", "LAST REPLY": "ПОСЛЕДНИЙ ОТВЕТ", "QUESTION": "ВОПРОС", diff --git a/internal/i18n/locales/zh-Hans.json b/internal/i18n/locales/zh-Hans.json index 362f1ac..cdfb5a5 100644 --- a/internal/i18n/locales/zh-Hans.json +++ b/internal/i18n/locales/zh-Hans.json @@ -1,4 +1,5 @@ { + "This approval action must be handled in Codex.": "此审批操作必须在 Codex 中处理。", "JUSTIFICATION": "理由", "COMMAND": "命令", "WORKING DIRECTORY": "工作目录", @@ -8,7 +9,7 @@ "[ Click here or press Enter to write ]": "[ 点击此处或按 Enter 输入 ]", "Enter: send / next answer • Esc: leave editor • ↑/↓: choices": "Enter:发送/下一题 • Esc:退出编辑 • ↑/↓:选项", "Sending…": "正在发送…", - "Text sent…": "文本已发送…", + "Text sent ...": "文本已发送 ...", "Send unconfirmed; check Codex before retrying.": "发送未确认;重试前请检查 Codex。", "Prompt changed; review the session before replying.": "提示已更改;回复前请检查会话。", "Choose an offered answer with ↑/↓.": "使用 ↑/↓ 选择提供的答案。", @@ -39,7 +40,7 @@ "[ CONFIRM APPROVAL ]": "[ 确认批准 ]", "[ DECLINE ]": "[ 拒绝 ]", "Sending decision…": "正在发送决定…", - "Decision sent…": "决定已发送…", + "Decision sent ...": "决定已发送 ...", "Decision unconfirmed; check Codex. Do not retry here.": "决定未确认;请检查 Codex。不要在此重试。", "LAST REPLY": "最近回复", "QUESTION": "问题", diff --git a/internal/ui/model.go b/internal/ui/model.go index 45df3fd..93a4c75 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -52,6 +52,7 @@ type Model struct { monitorApprovalConfirm string monitorApprovalBusy bool monitorApprovalNotice string + monitorApprovalNoticeToken string history accountHistoryState resetThreshold int resetHovered bool @@ -431,11 +432,12 @@ func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) { case monitorApprovalResult: m.monitorApprovalBusy = false m.monitorApprovalConfirm = "" - if m.monitorContextTarget() != message.sessionID { + session, exists := m.contextDetailSession() + if m.monitorContextTarget() != message.sessionID || !exists || session.preview.ApprovalToken != message.token { m.monitorApprovalNotice = "" return m, nil } - m.monitorApprovalNotice = i18n.Text("Decision sent…") + m.monitorApprovalNotice = i18n.Text("Decision sent ...") if message.err != nil { m.monitorApprovalNotice = i18n.Text("Decision unconfirmed; check Codex. Do not retry here.") } @@ -2127,7 +2129,7 @@ func (m *Model) syncMonitorCodexStatus(usage codex.LiveUsageSnapshot) { func (m Model) monitorHasVisibleWaitingSession() bool { for _, session := range m.monitorSessionData { - if m.monitorSessionVisible(session) && session.attention != codex.SessionAttentionNone { + if m.monitorSessionVisible(session) && monitorNeedsAttention(session.attention) { return true } } @@ -2250,7 +2252,7 @@ func (m *Model) resumeMonitorSessions(usage codex.LiveUsageSnapshot, observedAt id: update.ID, workingDirectory: update.WorkingDirectory, baseline: update.TotalTokens, latest: update.TotalTokens, graphStart: update.TotalTokens, startedAt: observedAt, lastActivity: update.LastActivity, agentCount: update.AgentCount, - active: update.Active, attention: update.Attention, displayed: update.Active, + active: update.Active, attention: update.Attention, preview: update.Context, displayed: update.Active, unattributed: update.Unattributed, callSequence: latestModelCallSequence(update.ModelCalls), turnSequence: latestTurnTimingSequence(update.TurnTimings), }) diff --git a/internal/ui/monitor.go b/internal/ui/monitor.go index ae294b4..398586f 100644 --- a/internal/ui/monitor.go +++ b/internal/ui/monitor.go @@ -288,6 +288,10 @@ func (m Model) renderMonitorSessionRow(width, height int, session monitorSession return lipgloss.JoinHorizontal(lipgloss.Top, metrics, " ", graph) } +func monitorNeedsAttention(attention codex.SessionAttention) bool { + return attention == codex.SessionAttentionInput || attention == codex.SessionAttentionApproval || attention == codex.SessionAttentionCheck +} + func monitorSessionColumnWidths(width int) (int, int, bool) { const gap = 1 if width <= gap+1 { @@ -335,7 +339,11 @@ func (m Model) renderMonitorSessionMetrics(width, height int, session monitorSes usageLine := i18n.Format("%s TOKENS // %.0f%% LOCAL", formatTokens(total), share*100) lines := make([]string, 0, bodyRows) if session.attention != codex.SessionAttentionNone { - badge := lipgloss.NewStyle().Bold(true).Foreground(colors.background).Background(colors.warning) + badgeColor := colors.primary + if monitorNeedsAttention(session.attention) { + badgeColor = colors.warning + } + badge := lipgloss.NewStyle().Bold(true).Foreground(colors.background).Background(badgeColor) lines = append(lines, badge.Render(ansi.Truncate(" ● "+monitorSessionAttentionLabel(session)+" ", innerWidth, ""))) } if len(lines) < bodyRows { @@ -364,7 +372,7 @@ func (m Model) renderMonitorSessionMetrics(width, height int, session monitorSes lines[len(lines)-1] = colors.dimmed().Render(ansi.Truncate(pageLabel+" // PGUP/PGDN", innerWidth, "")) } borderColor := colors.primary - if session.attention != codex.SessionAttentionNone && session.id != m.monitorSelectedID { + if monitorNeedsAttention(session.attention) && session.id != m.monitorSelectedID { borderColor = colors.warning } action := "" diff --git a/internal/ui/monitor_approval.go b/internal/ui/monitor_approval.go index 0b4ba04..5bf0f60 100644 --- a/internal/ui/monitor_approval.go +++ b/internal/ui/monitor_approval.go @@ -14,9 +14,15 @@ import ( type monitorApprovalResult struct { sessionID string + token string err error } +func (m Model) monitorApprovalHasOutcome() bool { + s, ok := m.contextDetailSession() + return ok && s.preview.Kind == codex.SessionContextApproval && s.preview.ApprovalToken != "" && s.preview.ApprovalToken == m.monitorApprovalNoticeToken && (m.monitorApprovalBusy || m.monitorApprovalNotice != "") +} + func (m Model) monitorApprovalBlockReason(c codex.SessionContext) string { if m.monitorApprovalBusy { return i18n.Text("Sending decision…") @@ -28,6 +34,8 @@ func (m Model) monitorApprovalBlockReason(c codex.SessionContext) string { return i18n.Text("Local observation cannot answer live approvals.") } switch c.ApprovalBlocked { + case "approval-kind": + return i18n.Text("This approval action must be handled in Codex.") case "network": return i18n.Text("Network approval is not supported here.") case "permissions": @@ -243,11 +251,12 @@ func (m Model) monitorApprovalAction(action string) (Model, tea.Cmd, bool) { m.monitorApprovalConfirm = "" m.monitorApprovalBusy = true m.monitorApprovalNotice = i18n.Text("Sending decision…") + m.monitorApprovalNoticeToken = token p := m.fetcher.(codex.SessionApprovalClient) id := m.monitorContextTarget() return m, func() tea.Msg { ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() - return monitorApprovalResult{id, p.RespondSessionApproval(ctx, token, decision)} + return monitorApprovalResult{id, token, p.RespondSessionApproval(ctx, token, decision)} }, true } diff --git a/internal/ui/monitor_approval_test.go b/internal/ui/monitor_approval_test.go index 12994bc..fd6ad5a 100644 --- a/internal/ui/monitor_approval_test.go +++ b/internal/ui/monitor_approval_test.go @@ -2,6 +2,7 @@ package ui import ( "context" + "errors" "strconv" "strings" "testing" @@ -13,6 +14,41 @@ import ( "github.com/merefield/codexometer/internal/i18n" ) +func TestMonitorDecisionOutcomeDoesNotAskForAnotherReply(t *testing.T) { + for _, failed := range []bool{false, true} { + m := approvalTestModel() + m, _, _ = m.monitorApprovalAction("decision:0") + m, cmd, _ := m.monitorApprovalAction("decision:0") + if strings.Contains(strings.Join(m.contextDetailLines(120), "\n"), i18n.Text("REPLY IN CODEX")) { + t.Fatal("sending decision asks for another reply") + } + result := cmd().(monitorApprovalResult) + if failed { + result.err = errors.New("ambiguous write") + } + n, _ := m.Update(result) + m = n.(Model) + if strings.Contains(strings.Join(m.contextDetailLines(120), "\n"), i18n.Text("REPLY IN CODEX")) { + t.Fatal("outcome asks for another reply") + } + if !m.monitorApprovalHasOutcome() { + t.Fatal("outcome was not retained") + } + m.monitorSessionData[0].preview.ApprovalToken = "replacement" + if m.monitorApprovalHasOutcome() { + t.Fatal("old result hides new request") + } + if !strings.Contains(strings.Join(m.contextDetailLines(120), "\n"), i18n.Text("REPLY IN CODEX")) { + t.Fatal("unavailable replacement diagnostic hidden") + } + n, _ = m.Update(result) + m = n.(Model) + if m.monitorApprovalNotice != "" { + t.Fatal("late old outcome applied to replacement") + } + } +} + type approvalTestClient struct{ token, decision string } func (c *approvalTestClient) Fetch(context.Context) (codex.Snapshot, error) { diff --git a/internal/ui/monitor_context.go b/internal/ui/monitor_context.go index 33d0a0d..6de3eac 100644 --- a/internal/ui/monitor_context.go +++ b/internal/ui/monitor_context.go @@ -132,7 +132,7 @@ func (m Model) renderMonitorContextDetail(width, height int, colors palette) str controls = m.renderMonitorApprovalControls(width, height, colors) } else if m.monitorPromptRows(width, height) > 0 { controls = m.renderMonitorPrompt(width, height, colors) - } else if m.monitorApprovalNotice != "" { + } else if m.monitorApprovalHasOutcome() { controls = colors.label().Render(ansi.Truncate(m.monitorApprovalNotice, max(width-4, 1), "")) } controlRows := 0 @@ -205,7 +205,7 @@ func (m *Model) scrollMonitorContext(delta int) { if n == 0 { n = m.monitorPromptRows(g.contentWidth, g.meterHeight) } - if n == 0 && m.monitorApprovalNotice != "" { + if n == 0 && m.monitorApprovalHasOutcome() { n = 1 } rows, _, _ := monitorContextBodyLayout(g.meterHeight, n) diff --git a/internal/ui/monitor_context_modes.go b/internal/ui/monitor_context_modes.go index 575f11e..da0c099 100644 --- a/internal/ui/monitor_context_modes.go +++ b/internal/ui/monitor_context_modes.go @@ -143,7 +143,7 @@ func (m Model) renderExpandedContext(width, height int, s monitorSession, colors if len(buttons) > 0 { n = buttons[len(buttons)-1].y + 1 controls = m.renderMonitorApprovalControls(width, height, colors) - } else if m.monitorApprovalNotice != "" { + } else if m.monitorApprovalHasOutcome() { n = 1 controls = colors.label().Render(ansi.Truncate(m.monitorApprovalNotice, max(width-4, 1), "")) } diff --git a/internal/ui/monitor_context_test.go b/internal/ui/monitor_context_test.go index b131461..409759d 100644 --- a/internal/ui/monitor_context_test.go +++ b/internal/ui/monitor_context_test.go @@ -106,6 +106,38 @@ func TestMonitorCompletionIsIndependentOfPreview(t *testing.T) { } } +func TestMonitorCompletionDoesNotRequestAttention(t *testing.T) { + m := contextTestModel() + m.monitorSessionData[0].attention = codex.SessionAttentionComplete + if m.monitorHasVisibleWaitingSession() { + t.Fatal("completion pulses waiting indicator") + } + colors := paletteFor(themeHacker) + s := m.monitorSessionData[0] + label := " ● " + monitorSessionAttentionLabel(s) + " " + out := m.renderMonitorSessionMetrics(70, 12, s, "", colors) + want := lipgloss.NewStyle().Bold(true).Foreground(colors.background).Background(colors.primary).Render(label) + if !strings.Contains(out, want) { + t.Fatal("completion does not use informational theme badge") + } + for _, a := range []codex.SessionAttention{codex.SessionAttentionInput, codex.SessionAttentionApproval, codex.SessionAttentionCheck} { + m.monitorSessionData[1].attention = a + if !m.monitorHasVisibleWaitingSession() { + t.Fatal("real attention signal lost", a) + } + } +} + +func TestMonitorResumeNewSessionRetainsContext(t *testing.T) { + m := contextTestModel() + preview := codex.SessionContext{Kind: codex.SessionContextQuestion, Text: "Please choose", ThreadID: "new", Source: "LIVE", InputToken: "question"} + m.resumeMonitorSessions(codex.LiveUsageSnapshot{Sessions: []codex.LiveUsageSession{{ID: "new", Active: true, Context: preview, Attention: codex.SessionAttentionInput}}}, time.Now(), time.Minute) + i := m.monitorSessionIndex("new") + if i < 0 || m.monitorSessionData[i].preview != preview { + t.Fatal("new resume branch lost context") + } +} + func TestMonitorContextResponsiveHitTargets(t *testing.T) { for _, size := range []struct{ w, h int }{{40, 16}, {60, 24}, {80, 24}, {100, 30}, {120, 40}, {180, 50}} { m := contextTestModel() diff --git a/internal/ui/monitor_detail.go b/internal/ui/monitor_detail.go index 4cb29e9..3a7ed6e 100644 --- a/internal/ui/monitor_detail.go +++ b/internal/ui/monitor_detail.go @@ -62,7 +62,7 @@ func (m Model) contextDetailDocument(width int) []detailLine { } appendText(c.Source+" // "+c.ThreadID+" // "+contextAge(c), "metadata", "") g := m.dashboardLayout() - if (c.Kind == codex.SessionContextApproval || c.Kind == codex.SessionContextQuestion) && !m.monitorApprovalControls(g.contentWidth, g.meterHeight) && m.monitorPromptRows(g.contentWidth, g.meterHeight) == 0 { + if (c.Kind == codex.SessionContextApproval || c.Kind == codex.SessionContextQuestion) && !m.monitorApprovalHasOutcome() && !m.monitorApprovalControls(g.contentWidth, g.meterHeight) && m.monitorPromptRows(g.contentWidth, g.meterHeight) == 0 { section(i18n.Text("REPLY IN CODEX")) appendText(m.monitorApprovalBlockReason(c), "warning", "") } diff --git a/internal/ui/monitor_prompt.go b/internal/ui/monitor_prompt.go index e7d10ad..26feebb 100644 --- a/internal/ui/monitor_prompt.go +++ b/internal/ui/monitor_prompt.go @@ -132,7 +132,7 @@ func (m Model) updateMonitorPrompt(msg tea.Msg) (Model, tea.Cmd, bool) { p.input.Reset() p.input.Blur() p.answers = [3]string{} - p.notice = i18n.Text("Text sent…") + p.notice = i18n.Text("Text sent ...") if result.err != nil { p.notice = i18n.Text("Send unconfirmed; check Codex before retrying.") } From f37ef48b52caa14137e6a963f319d9deb15745ec Mon Sep 17 00:00:00 2001 From: merefield Date: Sun, 6 Sep 2026 16:47:18 +0100 Subject: [PATCH 7/9] DOC: clarify explicit Monitor replies and follow-up actions --- README.md | 9 ++++++--- intro-post.md | 2 +- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index eea3413..6fed0d4 100644 --- a/README.md +++ b/README.md @@ -487,9 +487,12 @@ When the managed shared daemon is available, Codexometer also keeps a local app-server subscription for already-loaded thread IDs. From that stream it retains runtime flags, model-reroute/token-usage correlations, and bounded context from assistant messages and input/approval requests. Resolved requests -and new turns clear stale pending context. This connection never starts a turn -or answers a question. It can send an explicitly selected command decision, -with confirmation for permission grants, from the Monitor detail page (see below). +and new turns clear stale pending context. Observation alone never starts a turn +or answers a question. Explicit actions in Monitor can submit follow-up turns +to idle sessions, answer supported blocking questions, and send selected command +decisions (with confirmation for permission grants). Drafts stay in memory; +submitted text enters Codex's normal session history and follow-ups consume +model usage. Nothing is sent automatically; see the Monitor controls below. Without a shared daemon, previews use available local rollout text rather than fetching full thread histories. Missing request details are left unavailable; there is no extra model call. diff --git a/intro-post.md b/intro-post.md index 2f2151a..4cf33f5 100644 --- a/intro-post.md +++ b/intro-post.md @@ -91,7 +91,7 @@ Prefer a non-interactive run? `codexometer --digbench-game P-1` runs a named gam ## Privacy, authentication, and cost -Quota monitoring uses the prevailing Codex login. Monitor can show a bounded last-reply, activity, question, or approval preview beside each session, with an `[i]` detail view. Excerpts remain in memory and do not trigger another model call; `h` hides them, and that display preference is remembered. Questions and unsupported approvals are answered in Codex. Supported shared-server command decisions require explicit clicks; broader grants require a separately labelled confirmation and persistent-prefix rules are shown for review. Full interaction capture remains restricted to isolated benchmark turns created by Codexometer; Monitor does not retain user prompts, reasoning, or arbitrary tool output. +Quota monitoring uses the prevailing Codex login. Monitor can show a bounded last-reply, activity, question, or approval preview beside each session, with an `[i]` detail view. Excerpts remain in memory and do not trigger another model call; `h` hides them, and that display preference is remembered. With a shared app-server, full detail lets you answer supported blocking questions or explicitly send a follow-up to an idle session. Unsupported questions and approvals remain in Codex. Supported command decisions use clickable buttons or numbered shortcuts; grants require a separately labelled confirmation (`C` or a second click), and persistent-prefix rules are shown for review. Drafts stay in memory; submitted text becomes part of Codex's normal session history, and follow-up turns consume model usage. Full interaction capture remains restricted to isolated benchmark turns created by Codexometer; Monitor does not harvest user prompts, reasoning, or arbitrary tool output. Benchmark transcripts are bounded and sanitized. Credentials, request headers, known runtime identifiers, temporary paths, terminal controls, Codex reasoning, and unrelated local session content are not retained in the detail view. From a641ceae9b7d0f1a707f6f03101ee2c042d8e05c Mon Sep 17 00:00:00 2001 From: merefield Date: Sun, 6 Sep 2026 16:55:06 +0100 Subject: [PATCH 8/9] UI: clarify responsive Monitor detail toggle labels --- README.md | 3 ++- internal/i18n/locales/de.json | 2 ++ internal/i18n/locales/en-GB.json | 2 ++ internal/i18n/locales/es.json | 2 ++ internal/i18n/locales/fr.json | 2 ++ internal/i18n/locales/it.json | 2 ++ internal/i18n/locales/ja.json | 2 ++ internal/i18n/locales/nl.json | 2 ++ internal/i18n/locales/ru.json | 2 ++ internal/i18n/locales/zh-Hans.json | 2 ++ internal/ui/monitor.go | 2 +- internal/ui/monitor_context.go | 16 ++++++++++++++-- internal/ui/monitor_context_test.go | 14 ++++++++++++++ 13 files changed, 49 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 6fed0d4..1bd85ce 100644 --- a/README.md +++ b/README.md @@ -1143,7 +1143,8 @@ truncated or sanitised text, local-only observation, or a resolved/disconnected request. Eligible requests on small terminals instead explain that the terminal must be enlarged. These diagnostics do not relax any approval safeguards. -The readout's `[H:HIDE]`/`[H:SHOW]` button or `h` toggles all previews; the choice +The readout's `[ H: HIDE DETAIL ]`/`[ H: SHOW DETAIL ]` button or `h` toggles all +previews (shortened to `[H:HIDE]`/`[H:SHOW]` in narrow layouts); the choice survives restarts. No excerpt is saved. Each retained excerpt is capped at 4,096 Unicode characters; startup reads only a bounded 256 KiB rollout tail, so older context can be unavailable. Terminal escapes and control/bidirectional-formatting diff --git a/internal/i18n/locales/de.json b/internal/i18n/locales/de.json index ad5a47e..80b0b4e 100644 --- a/internal/i18n/locales/de.json +++ b/internal/i18n/locales/de.json @@ -1,4 +1,6 @@ { + "[ H: HIDE DETAIL ]": "[ H: DETAILS VERBERGEN ]", + "[ H: SHOW DETAIL ]": "[ H: DETAILS ZEIGEN ]", "This approval action must be handled in Codex.": "Diese Genehmigungsaktion muss in Codex bearbeitet werden.", "JUSTIFICATION": "BEGRÜNDUNG", "COMMAND": "BEFEHL", diff --git a/internal/i18n/locales/en-GB.json b/internal/i18n/locales/en-GB.json index 2dd4992..01efc9e 100644 --- a/internal/i18n/locales/en-GB.json +++ b/internal/i18n/locales/en-GB.json @@ -1,4 +1,6 @@ { + "[ H: HIDE DETAIL ]": "[ H: HIDE DETAIL ]", + "[ H: SHOW DETAIL ]": "[ H: SHOW DETAIL ]", "This approval action must be handled in Codex.": "This approval action must be handled in Codex.", "JUSTIFICATION": "JUSTIFICATION", "COMMAND": "COMMAND", diff --git a/internal/i18n/locales/es.json b/internal/i18n/locales/es.json index d9e66a5..70744c1 100644 --- a/internal/i18n/locales/es.json +++ b/internal/i18n/locales/es.json @@ -1,4 +1,6 @@ { + "[ H: HIDE DETAIL ]": "[ H: OCULTAR DETALLES ]", + "[ H: SHOW DETAIL ]": "[ H: MOSTRAR DETALLES ]", "This approval action must be handled in Codex.": "Esta autorización debe gestionarse en Codex.", "JUSTIFICATION": "JUSTIFICACIÓN", "COMMAND": "COMANDO", diff --git a/internal/i18n/locales/fr.json b/internal/i18n/locales/fr.json index ea95ebf..420d9ae 100644 --- a/internal/i18n/locales/fr.json +++ b/internal/i18n/locales/fr.json @@ -1,4 +1,6 @@ { + "[ H: HIDE DETAIL ]": "[ H: MASQUER LES DÉTAILS ]", + "[ H: SHOW DETAIL ]": "[ H: AFFICHER LES DÉTAILS ]", "This approval action must be handled in Codex.": "Cette autorisation doit être traitée dans Codex.", "JUSTIFICATION": "JUSTIFICATION", "COMMAND": "COMMANDE", diff --git a/internal/i18n/locales/it.json b/internal/i18n/locales/it.json index f25f37b..2113c3f 100644 --- a/internal/i18n/locales/it.json +++ b/internal/i18n/locales/it.json @@ -1,4 +1,6 @@ { + "[ H: HIDE DETAIL ]": "[ H: NASCONDI DETTAGLI ]", + "[ H: SHOW DETAIL ]": "[ H: MOSTRA DETTAGLI ]", "This approval action must be handled in Codex.": "Questa autorizzazione deve essere gestita in Codex.", "JUSTIFICATION": "MOTIVAZIONE", "COMMAND": "COMANDO", diff --git a/internal/i18n/locales/ja.json b/internal/i18n/locales/ja.json index bbab2b6..b455e09 100644 --- a/internal/i18n/locales/ja.json +++ b/internal/i18n/locales/ja.json @@ -1,4 +1,6 @@ { + "[ H: HIDE DETAIL ]": "[ H: 詳細を非表示 ]", + "[ H: SHOW DETAIL ]": "[ H: 詳細を表示 ]", "This approval action must be handled in Codex.": "この承認操作は Codex で行ってください。", "JUSTIFICATION": "理由", "COMMAND": "コマンド", diff --git a/internal/i18n/locales/nl.json b/internal/i18n/locales/nl.json index cacd93b..bd74446 100644 --- a/internal/i18n/locales/nl.json +++ b/internal/i18n/locales/nl.json @@ -1,4 +1,6 @@ { + "[ H: HIDE DETAIL ]": "[ H: DETAILS VERBERGEN ]", + "[ H: SHOW DETAIL ]": "[ H: DETAILS TONEN ]", "This approval action must be handled in Codex.": "Deze goedkeuringsactie moet in Codex worden afgehandeld.", "JUSTIFICATION": "TOELICHTING", "COMMAND": "OPDRACHT", diff --git a/internal/i18n/locales/ru.json b/internal/i18n/locales/ru.json index b5a2d44..f49db04 100644 --- a/internal/i18n/locales/ru.json +++ b/internal/i18n/locales/ru.json @@ -1,4 +1,6 @@ { + "[ H: HIDE DETAIL ]": "[ H: СКРЫТЬ ПОДРОБНОСТИ ]", + "[ H: SHOW DETAIL ]": "[ H: ПОКАЗАТЬ ПОДРОБНОСТИ ]", "This approval action must be handled in Codex.": "Это разрешение нужно обработать в Codex.", "JUSTIFICATION": "ОБОСНОВАНИЕ", "COMMAND": "КОМАНДА", diff --git a/internal/i18n/locales/zh-Hans.json b/internal/i18n/locales/zh-Hans.json index cdfb5a5..e826e0c 100644 --- a/internal/i18n/locales/zh-Hans.json +++ b/internal/i18n/locales/zh-Hans.json @@ -1,4 +1,6 @@ { + "[ H: HIDE DETAIL ]": "[ H: 隐藏详情 ]", + "[ H: SHOW DETAIL ]": "[ H: 显示详情 ]", "This approval action must be handled in Codex.": "此审批操作必须在 Codex 中处理。", "JUSTIFICATION": "理由", "COMMAND": "命令", diff --git a/internal/ui/monitor.go b/internal/ui/monitor.go index 398586f..91e1cc7 100644 --- a/internal/ui/monitor.go +++ b/internal/ui/monitor.go @@ -174,7 +174,7 @@ func (m Model) renderMonitorReadout(width, height int, colors palette) string { } action := "" if len(m.monitorSessionData) > 0 && width >= 16 { - action = m.renderContextAction("privacy", m.monitorPrivacyLabel(), colors) + action = m.renderContextAction("privacy", m.monitorPrivacyLabel(width), colors) } return frameSizedWithTitleAction(width, max(height-2, 1), i18n.Text("MONITOR READOUT"), action, strings.Join(lines, "\n"), colors.primary, colors) } diff --git a/internal/ui/monitor_context.go b/internal/ui/monitor_context.go index 6de3eac..389fbd2 100644 --- a/internal/ui/monitor_context.go +++ b/internal/ui/monitor_context.go @@ -30,7 +30,19 @@ func contextTitle(c codex.SessionContext) string { } } -func (m Model) monitorPrivacyLabel() string { +func (m Model) monitorPrivacyLabel(widths ...int) string { + g := m.dashboardLayout() + width := layoutMonitorArea(g.contentWidth, g.meterHeight).readoutWidth + if len(widths) > 0 { + width = widths[0] + } + hide, show := i18n.Text("[ H: HIDE DETAIL ]"), i18n.Text("[ H: SHOW DETAIL ]") + if width >= max(lipgloss.Width(hide), lipgloss.Width(show))+8 { + if m.monitorContextHidden { + return show + } + return hide + } if m.monitorContextHidden { return i18n.Text("[H:SHOW]") } @@ -308,7 +320,7 @@ func (m Model) monitorContextAt(x, y int) string { } a := layoutMonitorArea(g.contentWidth, g.meterHeight) if len(m.monitorSessionData) > 0 && a.readoutWidth >= 16 { - if r, ok := contextActionRect(a.readoutWidth, 0, m.monitorPrivacyLabel()); ok && r.contains(x, y) { + if r, ok := contextActionRect(a.readoutWidth, 0, m.monitorPrivacyLabel(a.readoutWidth)); ok && r.contains(x, y) { return "privacy" } } diff --git a/internal/ui/monitor_context_test.go b/internal/ui/monitor_context_test.go index 409759d..2b137e1 100644 --- a/internal/ui/monitor_context_test.go +++ b/internal/ui/monitor_context_test.go @@ -280,3 +280,17 @@ func TestMonitorContextPrivacyAndDismissRenderedTargets(t *testing.T) { } } } + +func TestMonitorDetailToggleLabelsAdaptToWidth(t *testing.T) { + m := contextTestModel() + for _, hidden := range []bool{false, true} { + m.monitorContextHidden = hidden + full, compact := i18n.Text("[ H: HIDE DETAIL ]"), i18n.Text("[H:HIDE]") + if hidden { + full, compact = i18n.Text("[ H: SHOW DETAIL ]"), i18n.Text("[H:SHOW]") + } + if m.monitorPrivacyLabel(100) != full || m.monitorPrivacyLabel(16) != compact { + t.Fatal("unexpected detail toggle label") + } + } +} From 6041c2e6cffde38f99a91028cb123ebe41bde4fc Mon Sep 17 00:00:00 2001 From: merefield Date: Sun, 6 Sep 2026 17:04:54 +0100 Subject: [PATCH 9/9] FEAT: add Swedish, Norwegian, Turkish, Estonian and Finnish --- README.md | 14 +- internal/i18n/i18n.go | 4 +- internal/i18n/i18n_test.go | 19 ++ internal/i18n/locales/et.json | 363 +++++++++++++++++++++++++++++++ internal/i18n/locales/fi.json | 363 +++++++++++++++++++++++++++++++ internal/i18n/locales/nb.json | 363 +++++++++++++++++++++++++++++++ internal/i18n/locales/sv.json | 363 +++++++++++++++++++++++++++++++ internal/i18n/locales/tr.json | 363 +++++++++++++++++++++++++++++++ internal/i18n/locales/units.json | 90 ++++++++ internal/ui/localisation_test.go | 2 +- intro-post.md | 5 +- 11 files changed, 1941 insertions(+), 8 deletions(-) create mode 100644 internal/i18n/locales/et.json create mode 100644 internal/i18n/locales/fi.json create mode 100644 internal/i18n/locales/nb.json create mode 100644 internal/i18n/locales/sv.json create mode 100644 internal/i18n/locales/tr.json diff --git a/README.md b/README.md index 1bd85ce..40e5d0c 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ depending on the selected authentication. The four primary tabs are **Quota**, **Monitor**, **Usage**, and **Benchmark**. The interface supports mouse controls, keyboard navigation, five colour themes, -and [nine languages](#language), with the original UK English presentation +and [fourteen languages](#language), with the original UK English presentation unchanged by default. ## Why use it? @@ -73,10 +73,16 @@ To select another interface language, set `CODEXOMETER_LANG` before starting: | Russian | `ru` | | Japanese | `ja` | | Chinese (Simplified) | `zh-Hans` | +| Swedish | `sv` | +| Norwegian (Bokmål) | `nb` | +| Turkish | `tr` | +| Estonian | `et` | +| Finnish | `fi` | Codes use BCP 47 language tags. Regional variants such as `de-DE`, `fr-CA`, -`ja-JP` and `zh-CN` match the corresponding supported language. Other English -variants use the existing UK English presentation. An unset, invalid or +`ja-JP`, `zh-CN`, `sv-SE`, `nb-NO`, `tr-TR`, `et-EE` and `fi-FI` match the +corresponding supported language. `no` and `no-NO` also select Norwegian Bokmål. +Other English variants use the existing UK English presentation. An unset, invalid or unsupported code falls back to UK English. `LANG` and `LC_ALL` are deliberately not used to choose the UI language: the default stays English unless you opt in. @@ -85,6 +91,8 @@ Try a language for one launch: ```sh CODEXOMETER_LANG=fr codexometer CODEXOMETER_LANG=ja codexometer --demo +CODEXOMETER_LANG=sv codexometer +CODEXOMETER_LANG=nb codexometer --demo ``` ### Retain the language setting diff --git a/internal/i18n/i18n.go b/internal/i18n/i18n.go index ada5923..4d40302 100644 --- a/internal/i18n/i18n.go +++ b/internal/i18n/i18n.go @@ -22,8 +22,8 @@ const EnvironmentVariable = "CODEXOMETER_LANG" //go:embed locales/*.json var files embed.FS -var codes = []string{"en-GB", "nl", "de", "fr", "it", "es", "ru", "ja", "zh-Hans"} -var tags = []language.Tag{language.BritishEnglish, language.Dutch, language.German, language.French, language.Italian, language.Spanish, language.Russian, language.Japanese, language.SimplifiedChinese} +var codes = []string{"en-GB", "nl", "de", "fr", "it", "es", "ru", "ja", "zh-Hans", "sv", "nb", "tr", "et", "fi"} +var tags = []language.Tag{language.BritishEnglish, language.Dutch, language.German, language.French, language.Italian, language.Spanish, language.Russian, language.Japanese, language.SimplifiedChinese, language.Swedish, language.MustParse("nb"), language.Turkish, language.Estonian, language.Finnish} var matcher = language.NewMatcher(tags) var textCatalog, formatCatalog = loadCatalogues() var current = New(os.Getenv(EnvironmentVariable)) diff --git a/internal/i18n/i18n_test.go b/internal/i18n/i18n_test.go index 72aa6e3..b50b243 100644 --- a/internal/i18n/i18n_test.go +++ b/internal/i18n/i18n_test.go @@ -69,6 +69,15 @@ func TestPresentationKeysHaveCatalogueEntries(t *testing.T) { } func TestLanguageSelection(t *testing.T) { + for input, want := range map[string]string{ + "sv": "sv", "sv-SE": "sv", "sv-FI": "sv", + "nb": "nb", "nb-NO": "nb", "no": "nb", "no-NO": "nb", + "tr": "tr", "tr-TR": "tr", "et": "et", "et-EE": "et", "fi": "fi", "fi-FI": "fi", + } { + if got := New(input).Code(); got != want { + t.Errorf("%q: %q, want %q", input, got, want) + } + } for input, want := range map[string]string{"": "en-GB", "en": "en-GB", "en-US": "en-GB", "en-GB": "en-GB", "nl-NL": "nl", "de-AT": "de", "fr-CA": "fr", "it": "it", "es-MX": "es", "ru-RU": "ru", "ja-JP": "ja", "zh-CN": "zh-Hans", "zh-Hans": "zh-Hans", "zz": "en-GB", "not a language": "en-GB", " de ": "de"} { if got := New(input).Code(); got != want { t.Errorf("%q: %q, want %q", input, got, want) @@ -129,6 +138,11 @@ func TestCataloguesAndEnglishCompatibility(t *testing.T) { } func TestLiteralFallbackAndTranslations(t *testing.T) { + for code, want := range map[string]string{"sv": "ANVÄNDNING", "nb": "BRUK", "tr": "KULLANIM", "et": "KASUTUS", "fi": "KÄYTTÖ"} { + if got := New(code).Text("USAGE"); got != want { + t.Errorf("%s translation: %q, want %q", code, got, want) + } + } for _, code := range codes { tr := New(code) if got := tr.Text("untranslated 50% complete"); got != "untranslated 50% complete" { @@ -152,6 +166,11 @@ func TestWindowNameUsesLocalePluralRules(t *testing.T) { {"de", "1 WEEK", "1 WOCHE"}, {"de", "2 WEEKS", "2 WOCHEN"}, {"ru", "1 HOUR", "1 ЧАС"}, {"ru", "2 HOURS", "2 ЧАСА"}, {"ru", "5 HOURS", "5 ЧАСОВ"}, {"ru", "21 HOURS", "21 ЧАС"}, {"ja", "5 HOURS", "5 時間"}, {"zh-Hans", "1 DAY", "1 天"}, + {"sv", "1 HOUR", "1 TIMME"}, {"sv", "2 HOURS", "2 TIMMAR"}, + {"nb", "1 WEEK", "1 UKE"}, {"nb", "2 WEEKS", "2 UKER"}, + {"tr", "1 DAY", "1 GÜN"}, {"tr", "2 DAYS", "2 GÜN"}, + {"et", "1 MINUTE", "1 MINUT"}, {"et", "2 MINUTES", "2 MINUTIT"}, + {"fi", "1 WEEK", "1 VIIKKO"}, {"fi", "2 WEEKS", "2 VIIKKOA"}, {"fi", "0 HOURS", "0 TUNTIA"}, {"fr", "custom-model-id", "custom-model-id"}, } { if got := New(test.code).WindowName(test.name); got != test.want { diff --git a/internal/i18n/locales/et.json b/internal/i18n/locales/et.json new file mode 100644 index 0000000..d69edba --- /dev/null +++ b/internal/i18n/locales/et.json @@ -0,0 +1,363 @@ +{ + "[ H: HIDE DETAIL ]": "[ H: PEIDA ÜKSIKASJAD ]", + "[ H: SHOW DETAIL ]": "[ H: NÄITA ÜKSIKASJU ]", + "This approval action must be handled in Codex.": "See kinnitustoiming tuleb teha Codexis.", + "JUSTIFICATION": "PÕHJENDUS", + "COMMAND": "KÄSK", + "WORKING DIRECTORY": "TÖÖKATALOOG", + "PERSISTENT PERMISSION RULE": "PÜSIV ÕIGUSREEGEL", + "FOLLOW-UP": "JÄTKUKÜSIMUS", + "REPLY": "VASTUS", + "[ Click here or press Enter to write ]": "[ Kirjutamiseks klõpsa siin või vajuta Enter ]", + "Enter: send / next answer • Esc: leave editor • ↑/↓: choices": "Enter: saada / järgmine vastus • Esc: lõpeta sisestamine • ↑/↓: valikud", + "Sending…": "Saatmine…", + "Text sent ...": "Tekst saadetud ...", + "Send unconfirmed; check Codex before retrying.": "Saatmine kinnitamata; enne uuesti proovimist kontrolli Codexit.", + "Prompt changed; review the session before replying.": "Küsimus muutus; enne vastamist kontrolli seanssi.", + "Choose an offered answer with ↑/↓.": "Vali pakutud vastus klahvidega ↑/↓.", + "OPEN DETAIL": "AVA ÜKSIKASJAD", + "No supported approval choices were offered.": "Toetatud kinnitusvalikuid ei pakutud.", + "[ ALLOW FOR SESSION ]": "[ LUBA SEANSIKS ]", + "[ CONFIRM SESSION GRANT ]": "[ KINNITA SEANSI LUBA ]", + "[ ALWAYS ALLOW PREFIX ]": "[ LUBA PREFIKS ALATI ]", + "[ CONFIRM PERSISTENT RULE ]": "[ KINNITA PÜSIV REEGEL ]", + "[ REJECT & STOP TURN ]": "[ KEELDU JA PEATA VOOR ]", + "OFFERED DECISIONS": "PAKUTUD OTSUSED", + "Questions must be answered in Codex.": "Küsimustele tuleb vastata Codexis.", + "Local observation cannot answer live approvals.": "Kohalik vaatlus ei saa reaalajas kinnitusi anda.", + "Network approval is not supported here.": "Võrguloa kinnitamist siin ei toetata.", + "Additional permissions require approval in Codex.": "Lisaload tuleb kinnitada Codexis.", + "File-change approval is not supported here.": "Failimuudatuste kinnitamist siin ei toetata.", + "Unsupported command format.": "Toetamata käsuvorming.", + "Command details are missing.": "Käsu üksikasjad puuduvad.", + "Working directory is missing.": "Töökataloog puudub.", + "Turn or item identity is missing.": "Vooru või elemendi tunnus puudub.", + "One-time approval and decline are not both offered.": "Ühekordset kinnitust ja keeldumist ei pakuta mõlemat.", + "Request details exceed the display limit.": "Päringu üksikasjad ületavad kuvamispiirangu.", + "Request text was altered for safe display.": "Päringu teksti muudeti ohutuks kuvamiseks.", + "No actionable live approval was received.": "Töödeldavat reaalajas kinnitustaotlust ei saadud.", + "Request resolved, expired, or disconnected.": "Päring lahendatud, aegunud või ühendus katkenud.", + "Enlarge the terminal to show approval controls.": "Kinnitusnuppude nägemiseks suurenda terminali.", + "[ APPROVE ONCE ]": "[ KINNITA ÜKS KORD ]", + "[ CONFIRM APPROVAL ]": "[ KINNITA LUBA ]", + "[ DECLINE ]": "[ KEELDU ]", + "Sending decision…": "Otsuse saatmine…", + "Decision sent ...": "Otsus saadetud ...", + "Decision unconfirmed; check Codex. Do not retry here.": "Otsus kinnitamata; kontrolli Codexit. Ära siin uuesti proovi.", + "LAST REPLY": "VIIMANE VASTUS", + "QUESTION": "KÜSIMUS", + "REQUEST": "PÄRING", + "LAST ACTIVITY": "VIIMANE TEGEVUS", + "[H:SHOW]": "[H:NÄITA]", + "[H:HIDE]": "[H:PEIDA]", + "NO CONTEXT": "KONTEKST PUUDUB", + "REPLY IN CODEX": "VASTA CODEXIS", + "SESSION CONTEXT": "SEANSI KONTEKST", + "TURN COMPLETE": "VOOR LÕPETATUD", + "OBSERVED API-EQ // SPEND ~%s // 100%% ~%s // %s · N=%d": "VAADELDUD API-EQ // KULU ~%s // 100%% ~%s // %s · N=%d", + "MED": "KESKM", + "RESULT MATRIX": "TULEMUSTABEL", + "\nBENCHMARK TRANSCRIPT: UNAVAILABLE\n": "\nTESTI VÄLJAVÕTE: POLE SAADAVAL\n", + " // CREDITS ": " // KREDIIDID ", + " // CREDITS AVAILABLE": " // KREDIIDID SAADAVAL", + " // CREDITS UNLIMITED": " // PIIRAMATUD KREDIIDID", + " // RESET TOKENS %d": " // LÄHTESTUSTOKENID %d", + " // EFFORT // ": " // TASE // ", + " // LOCAL STALE": " // KOHALIK AEGUNUD", + " // NO INTEGER Δ": " // TÄISARVULINE Δ PUUDUB", + " // PEAK ": " // TIPP ", + " // PUBLISHED CONDITION WITH HIGH": " // AVALDATUD TINGIMUS HIGH TASEMEL", + " // R REFRESH": " // R VÄRSKENDA", + " // R RETRY": " // R PROOVI UUESTI", + " // RESET": " // LÄHTESTA", + " // SCALE ": " // SKAALA ", + " // SPACE TOGGLE // ESC CANCEL": " // TÜHIK VAHETA // ESC TÜHISTA", + " // STALE": " // AEGUNUD", + " // TIME // ": " // AEG // ", + " DAYS": " PÄEVA", + " HOURS": " TUNDI", + " MINUTES": " MINUTIT", + " MODELS // ": " MUDELID // ", + " ONLINE // ": " VÕRGUS // ", + " RANK //": " KOHT //", + " REASONING LEVELS // ": " ARUTLUSTASEMED // ", + " TOKENS": " TOKENID", + " WEEKS": " NÄDALAT", + "%s // %d WEEKS": "%s // %d NÄDALAT", + "%s // BENCHMARK-ONLY // LINES %d-%d/%d": "%s // AINULT TEST // READ %d-%d/%d", + "%s QUOTA TELEMETRY CONSOLE · VERSION %s": "%s KVOODITELEMEETRIA KONSOOL · VERSIOON %s", + "%s TOKENS // %.0f%% LOCAL": "%s TOKENIT // %.0f%% KOHALIK", + "(P)AUSE": "(P) PEATA", + "ACCOUNT": "KONTO", + "ACCOUNT // ": "KONTO // ", + "ACCOUNT HISTORY // UTC // %d WEEKS // R REFRESH": "KONTO AJALUGU // UTC // %d NÄDALAT // R VÄRSKENDA", + "ACCOUNT HISTORY // UTC // UPDATED ": "KONTO AJALUGU // UTC // VÄRSKENDATUD ", + "ACCOUNT QUOTA Δ": "KONTO KVOOT Δ", + "ACCOUNT QUOTA Δ // NO WINDOWS": "KONTO KVOOT Δ // PERIOODID PUUDUVAD", + "ACCOUNT QUOTA Δ // UNAVAILABLE": "KONTO KVOOT Δ // POLE SAADAVAL", + "ACQUIRING SIGNAL": "SIGNAALI OTSIMINE", + "ACTIVE": "AKTIIVNE", + "ACTUAL MODEL: %s\n": "TEGELIK MUDEL: %s\n", + "ALGORITHM TRIAL": "ALGORITMITEST", + "API-EQ // BENCHMARK ACTIVE": "API-EQ // TEST AKTIIVNE", + "API-EQ // BENCHMARK SETTLING": "API-EQ // TESTI ANDMED ÜHTLUSTUVAD", + "API-EQ DEFERRED // BENCHMARK ACCOUNTING SETTLING": "API-EQ EDASI LÜKATUD // TESTI ARVESTUS ÜHTLUSTUB", + "API-EQ DEFERRED // SUBSCRIPTION BENCHMARK ACTIVE": "API-EQ EDASI LÜKATUD // TELLIMUSE TEST AKTIIVNE", + "API-EQ LEARNING %d/%dPP": "API-EQ ÕPPIMINE %d/%dPP", + "API-EQ LEARNING // ": "API-EQ ÕPPIMINE // ", + "API-EQ LEARNING // %d/%dPP CLEAN MOVEMENT": "API-EQ ÕPPIMINE // %d/%dPP PUHAS MUUTUS", + "API-EQ LEARNING // %d/%dPP CLEAN MOVEMENT // RESTARTED: %s": "API-EQ ÕPPIMINE // %d/%dPP PUHAS MUUTUS // TAASKÄIVITATUD: %s", + "API-EQ N/A // ACCOUNT ATTRIBUTION UNKNOWN": "API-EQ N/A // KONTO SEOS TEADMATA", + "API-EQ N/A // LIMIT ATTRIBUTION UNKNOWN": "API-EQ N/A // PIIRANGU SEOS TEADMATA", + "API-EQ NOW ~%s // 100%% ~%s · %s%d": "API-EQ NÜÜD ~%s // 100%% ~%s · %s%d", + "APPROVAL": "KINNITUS", + "APPROVAL NEEDED": "KINNITUST VAJA", + "AUTO-SCAN %s": "AUTOMAATSKANN %s", + "BALANCED +0 POINTS // ON PACE": "TASAKAALUS +0 PUNKTI // TEMPOS", + "BARS": "TULBAD", + "BENCHMARK": "TEST", + "BENCHMARK // TERMINAL TOO SHORT": "TEST // TERMINAL LIIGA MADAL", + "BENCHMARK CONTROLS": "TESTI JUHTIMINE", + "BENCHMARK FAULT": "TESTIVIGA", + "BENCHMARK SCOPE // %d MODELS // %d EFFORTS // %d PAIRS": "TESTI VALIK // %d MUDELIT // %d TASET // %d PAARI", + "BENCHMARK TRANSCRIPT // UNAVAILABLE": "TESTI VÄLJAVÕTE // POLE SAADAVAL", + "BRAILLE PIE": "BRAILLE-SEKTOR", + "CALLS %d // LAST %s": "KUTSED %d // VIIMANE %s", + "CHECK": "KONTROLLI", + "CHECK ALL": "VALI KÕIK", + "CHECK SESSION": "KONTROLLI SEANSSI", + "CLEAR": "VABA", + "CLEAR ALL": "TÜHJENDA KÕIK", + "CODEXOMETER BENCHMARK RUN DETAIL\n": "CODEXOMETER TESTI ÜKSIKASJAD\n", + "COMPLETE // %d/%d PASS": "LÕPETATUD // %d/%d ÕNNESTUS", + "COMPLETED RESULTS RETAINED // PRESS B TO RUN AGAIN": "VALMIS TULEMUSED SÄILITATUD // UUESTI KÄIVITAMISEKS B", + "CONSUMPTION PACE": "KASUTUSTEMPO", + "CONSUMPTION PACE // TIME - USAGE": "KASUTUSTEMPO // AEG - KASUTUS", + "CREDITS": "KREDIIDID", + "CUMULATIVE TOKENS": "KUMULATIIVSED TOKENID", + "CYCLE START UNAVAILABLE // ": "PERIOODI ALGUS PUUDUB // ", + "DAILY ACTIVITY // %d WEEKS // UTC": "PÄEVANE TEGEVUS // %d NÄDALAT // UTC", + "DAILY TOKENS": "PÄEVASED TOKENID", + "DAYS": "PÄEVA", + "DEFICIT %+d POINTS // OVER PACE": "PUUDUJÄÄK %+d PUNKTI // ÜLE TEMPO", + "DISCOVERING MODELS": "MUDELITE OTSIMINE", + "DISCOVERING VISIBLE MODELS + EFFORTS": "NÄHTAVATE MUDELITE JA TASEMETE OTSIMINE", + "EFFORT": "TASE", + "EFFORT: %s\n": "TASE: %s\n", + "ELAPSED %s // RATE %s/MIN": "MÖÖDUNUD %s // KIIRUS %s/MIN", + "EQ // BENCH ACTIVE": "EQ // TEST AKTIIVNE", + "EQ NOW ~%s // FULL ~%s %s%d": "EQ NÜÜD ~%s // TÄIS ~%s %s%d", + "EST LOCAL-ONLY ": "AINULT KOHALIK HINNANG ", + "ESTABLISHING A FRESH BASELINE": "UUE LÄHTETASEME LOOMINE", + "EXHAUSTED": "AMMENDUNUD", + "EXTERNAL DIGBENCH // PERSISTED REMOTE SESSION // RANDOM SEED": "VÄLINE DIGBENCH // SALVESTATUD KAUGSEANSS // JUHUSEEME", + "Enlarge terminal for daily activity grid (7 weekday rows).": "Päevase tegevusruudustiku jaoks suurenda terminali (7 nädalapäevarida).", + "FAIL": "EBAÕNNESTUS", + "FAILURE": "VIGA", + "FAILURE // ": "VIGA // ", + "FETCHING ACCOUNT HISTORY…": "KONTO AJALOO TOOMINE…", + "FREE %3d%%": "VABA %3d%%", + "FRESH // GO!": "VÄRSKE // ALUSTA!", + "FUEL TANK": "KÜTUSEPAAK", + "HANDSHAKE WITH CODEX APP-SERVER IN PROGRESS": "ÜHENDUSE LOOMINE CODEX APP-SERVERIGA", + "HEADROOM %+d POINTS // UNDER PACE": "VARU %+d PUNKTI // ALLA TEMPO", + "HOURS": "TUNDI", + "IDLE": "JÕUDE", + "IN PROGRESS": "POOLELI", + "IN PROGRESS (LVL %d)": "POOLELI (TASE %d)", + "INCOMPLETE": "LÕPETAMATA", + "INITIALIZING LOCAL SESSION MONITOR": "KOHALIKU SEANSISEIRE ALGATAMINE", + "INPUT": "SISEND", + "INPUT NEEDED": "SISENDIT VAJA", + "INTERRUPTING CURRENT BENCHMARK TRIAL": "KÄIMASOLEVA TESTI KATKESTAMINE", + "LAST // ": "VIIMANE // ", + "LAST FAIL // ": "VIIMANE EBAÕNNESTUS // ", + "LAST N/A // ": "VIIMANE N/A // ", + "LAST OUT ": "VIIMANE VÄLJUND ", + "LESS ": "VÄHEM ", + "LIFETIME // %s TOKENS PEAK DAY // %s STREAK // %s DAYS": "KOGUAEG // %s TOKENIT TIPPPÄEV // %s JADA // %s PÄEVA", + "LIMIT NEAR": "PIIR LÄHEDAL", + "LINEAR PROJECTION // LIMIT IN ~%s // %s EARLY": "LINEAARNE PROGNOOS // PIIRINI ~%s // %s VAREM", + "LINEAR PROJECTION // SAFE THROUGH RESET // ~%d%% LEFT": "LINEAARNE PROGNOOS // JÄTKUB LÄHTESTUSENI // ~%d%% ALLES", + "LIVE LOCAL SESSIONS %d // P TO PAUSE": "KOHALIKUD REAALAJASEANSID %d // PEATAMISEKS P", + "LIVE RUN DETAIL // IN PROGRESS": "KÄIMASOLEVA TESTI ÜKSIKASJAD // POOLELI", + "LOCAL SESSIONS %d // LAST %s AGO": "KOHALIKUD SEANSID %d // VIIMATI %s TAGASI", + "LOCAL SESSIONS %d // P TO RESUME": "KOHALIKUD SEANSID %d // JÄTKAMISEKS P", + "LOCAL TELEMETRY UNAVAILABLE": "KOHALIK TELEMEETRIA PUUDUB", + "LOCAL TOKEN BARS": "KOHALIKUD TOKENITULBAD", + "LOW": "MADAL", + "MINUTES": "MINUTIT", + "MODEL": "MUDEL", + "MODEL // ": "MUDEL // ", + "MODEL: %s\n": "MUDEL: %s\n", + "MONITOR": "SEIRE", + "MONITOR READOUT": "SEIRENÄIDUD", + "MONITORING ●": "JÄLGIMINE ●", + "MONTHLY": "IGAKUINE", + "MORE // PEAK DAY %s": "ROHKEM // TIPPPÄEV %s", + "NEAR": "LÄHEDAL", + "NO COMPLETE 30 SEC SAMPLE": "TÄIELIK 30 S MÕÕTMINE PUUDUB", + "NO RESULTS MATCH THE ACTIVE FILTER": "AKTIIVSELE FILTRILE VASTAVAID TULEMUSI POLE", + "NO TOKEN SIGNAL": "TOKENISIGNAAL PUUDUB", + "OBSERVATION DEFERRED": "VAATLUS EDASI LÜKATUD", + "PACE DATA UNAVAILABLE": "TEMPOANDMED PUUDUVAD", + "PASS": "ÕNNESTUS", + "PAUSED": "PEATATUD", + "PAUSING": "PEATAMINE", + "PIE": "SEKTOR", + "PRESS B OR CLICK TO RUN AGAIN": "UUESTI KÄIVITAMISEKS VAJUTA B VÕI KLÕPSA", + "QUOTA": "KVOOT", + "QUOTA CLEAR": "KVOOT VABA", + "QUOTA EXHAUSTED": "KVOOT AMMENDUNUD", + "QUOTA RESET": "KVOODI LÄHTESTUS", + "QUOTA TELEMETRY // VERSION ": "KVOODITELEMEETRIA // VERSIOON ", + "QUOTA UNKNOWN": "KVOOT TEADMATA", + "QUOTA WATCH": "JÄLGI KVOOTI", + "RANK": "KOHT", + "RATE ": "KIIRUS ", + "RE(S)ET": "LÄHTESTA (S)", + "READING FINAL APPENDED TOKEN TELEMETRY": "VIIMASE LISATUD TOKENITELEMEETRIA LUGEMINE", + "READY": "VALMIS", + "REBASING AFTER PAUSED ACTIVITY": "LÄHTETASEME UUENDAMINE PÄRAST PAUSI", + "REMOTE SESSIONS": "KAUGSEANSID", + "RESET": "LÄHTESTA", + "RESET CYCLE %3d%%%s // %s": "LÄHTESTUSTSÜKKEL %3d%%%s // %s", + "RESET CYCLE // RESET DATA UNAVAILABLE": "LÄHTESTUSTSÜKKEL // LÄHTESTUSANDMED PUUDUVAD", + "RESET DATA UNAVAILABLE": "LÄHTESTUSANDMED PUUDUVAD", + "RESET FRESH // GO!": "UUS KVOOT // ALUSTA!", + "RESET T-%s": "LÄHTESTA T-%s", + "RESET T-%s // %s": "LÄHTESTA T-%s // %s", + "RESETTING": "LÄHTESTAMINE", + "RESULT": "TULEMUS", + "RESULT // ": "TULEMUS // ", + "RESULT MATRIX // STANDARD API-EQUIVALENT USD": "TULEMUSTABEL // STANDARDNE API-EKVIVALENT USD", + "RESULT: %s\n": "TULEMUS: %s\n", + "RESUME (P)": "JÄTKA (P)", + "RESUMING": "JÄTKAMINE", + "ROWS %d-%d/%d": "READ %d-%d/%d", + "ROWS %d-%d/%d // ↑ ↓ SELECT // ENTER OR CLICK DETAILS": "READ %d-%d/%d // ↑ ↓ VALI // ÜKSIKASJAD: ENTER VÕI KLÕPS", + "RUN DETAIL": "TESTI ÜKSIKASJAD", + "RUN SCOPE OR RUN ALL TO BEGIN // THIS CONSUMES CODEX QUOTA": "ALUSTAMISEKS KÄIVITA VALIK VÕI KÕIK // KASUTAB CODEXI KVOOTI", + "RUNNING %d/%d": "KÄIVITATUD %d/%d", + "SAMPLES %d // NEXT %s": "MÕÕTMISED %d // JÄRGMINE %s", + "SCANNING": "SKANNIMINE", + "SCANNING LOCAL CODEX TELEMETRY": "KOHALIKU CODEXI TELEMEETRIA SKANNIMINE", + "SESSION // ": "SEANSS // ", + "SHOW": "NÄITA", + "SHOW //": "NÄITA //", + "SIGNAL FAULT": "SIGNAALIVIGA", + "STALE": "AEGUNUD", + "STALE // ": "AEGUNUD // ", + "STALE SIGNAL": "AEGUNUD SIGNAAL", + "START %s // NOW %s": "ALGUS %s // NÜÜD %s", + "STARTING": "KÄIVITAMINE", + "STARTING MONITOR": "SEIRE KÄIVITAMINE", + "STOP ISSUE": "PEATAMISVIGA", + "STOP ISSUE // ": "PEATAMISVIGA // ", + "STOPPED": "PEATATUD", + "STOPPED // %d/%d COMPLETE": "PEATATUD // %d/%d LÕPETATUD", + "STOPPING // %d/%d COMPLETE": "PEATAMINE // %d/%d LÕPETATUD", + "TASK": "ÜLESANNE", + "TASK // ": "ÜLESANNE // ", + "TASK: %s\n": "ÜLESANNE: %s\n", + "THEME // ": "TEEMA // ", + "THEME // %s": "TEEMA // %s", + "TIME": "AEG", + "TIME: %s\n": "AEG: %s\n", + "TOKEN BARS": "TOKENITULBAD", + "TOKENS": "TOKENID", + "TOKENS // ": "TOKENID // ", + "TOKENS // N/A": "TOKENID // N/A", + "TOKENS: %s\n": "TOKENID: %s\n", + "TOKENS: N/A\n": "TOKENID: N/A\n", + "TOTAL %d // INPUT %d // CACHED %d // OUTPUT %d // REASONING %d": "KOKKU %d // SISEND %d // VAHEMÄLUS %d // VÄLJUND %d // ARUTLUS %d", + "TRANSCRIPT: BENCHMARK-ONLY\n": "VÄLJAVÕTE: AINULT TEST\n", + "TURNS": "VOORUD", + "Token activity history unavailable. Requires a supported Codex CLI and ChatGPT login.": "Tokenite tegevusajalugu pole saadaval. Nõuab toetatud Codex CLI-d ja ChatGPT sisselogimist.", + "UNAVAILABLE // ": "POLE SAADAVAL // ", + "UNKNOWN": "TEADMATA", + "UNKNOWN PLAN": "TUNDMATU PAKETT", + "USAGE": "KASUTUS", + "USED %3d%%": "KASUTATUD %3d%%", + "USES QUOTA // %d SELECTED MODEL + EFFORT PAIRS": "KASUTAB KVOOTI // %d VALITUD MUDELI JA TASEME PAARI", + "Use one reset? Refreshes eligible quota and changes the weekly reset schedule. Click CONFIRM; Esc cancels.": "Kas kasutada üht lähtestust? Uuendab sobivat kvooti ja muudab iganädalast lähtestusaega. Klõpsa KINNITA; Esc tühistab.", + "WAITING FOR FIRST RESULT": "ESIMESE TULEMUSE OOTAMINE", + "WAITING FOR FIRST SAMPLE": "ESIMESE MÕÕTMISE OOTAMINE", + "WATCH": "JÄLGI", + "WEEKLY TOKENS": "NÄDALASED TOKENID", + "WEEKS": "NÄDALAT", + "[ (1)2 MONTHS ]": "[ (1)2 KUUD ]", + "[ (6) MONTHS ]": "[ (6) KUUD ]", + "[ (A) RUN ALL // %d ]": "[ (A) KÄIVITA KÕIK // %d ]", + "[ (B) RUN SCOPE // %d REMOTE ]": "[ (B) KÄIVITA VALIK // %d KAUG ]", + "[ (B) RUN SCOPE // %d ]": "[ (B) KÄIVITA VALIK // %d ]", + "[ (C)UMULATIVE ]": "[ (C) KUMULATIIVNE ]", + "[ (D)AILY ]": "[ (D) PÄEVANE ]", + "[ (Q)UIT ]": "[ (Q) VÄLJU ]", + "[ (R)EFRESH ]": "[ (R) VÄRSKENDA ]", + "[ (S) SCOPE // %d ]": "[ (S) VALIK // %d ]", + "[ (T)HEME ]": "[ (T) TEEMA ]", + "[ (V)IEW ]": "[ (V) VAADE ]", + "[ (W)EEKLY ]": "[ (W) NÄDALANE ]", + "[ (X) STOP ]": "[ (X) PEATA ]", + "[ ALL ]": "[ KÕIK ]", + "[ CONFIRM // %d %s ]": "[ KINNITA // %d %s ]", + "[ CONFIRM // %d REMOTE SESSIONS ]": "[ KINNITA // %d KAUGSEANSSI ]", + "[ CONFIRM RESET ]": "[ KINNITA LÄHTESTAMINE ]", + "[ COST ]": "[ KULU ]", + "[ DISCOVERING TURNS… ]": "[ VOORUDE OTSIMINE… ]", + "[ FAIL ]": "[ EBAÕNNESTUS ]", + "[ NEWER → ]": "[ UUEMAD → ]", + "[ PASS ]": "[ ÕNNESTUS ]", + "[ RESET // %d ]": "[ LÄHTESTA // %d ]", + "[ RESETTING… ]": "[ LÄHTESTAMINE… ]", + "[ RETRY RESET ]": "[ PROOVI LÄHTESTAMIST UUESTI ]", + "[ SPEED ]": "[ KIIRUS ]", + "[ ← OLDER ]": "[ ← VANEMAD ]", + "[A:CONFIRM %d]": "[A:KINNITA %d]", + "[ALL:%d]": "[KÕIK:%d]", + "[ALL]": "[KÕIK]", + "[B:CONFIRM %d]": "[B:KINNITA %d]", + "[B:SCOPE %d]": "[B:VALIK %d]", + "[FAIL]": "[EBAÕNNESTUS]", + "[PASS]": "[ÕNNESTUS]", + "[S:SCOPE %d]": "[S:VALIK %d]", + "[SCOPE:%d]": "[VALIK:%d]", + "[STOP]": "[PEATA]", + "╭ BARS ╮": "╭ TULBAD ╮", + "╭ BENCHMARK ╮": "╭ TEST ╮", + "╭ CONSUMPTION PACE ╮": "╭ KASUTUSTEMPO ╮", + "╭ FUEL TANK ╮": "╭ KÜTUSEPAAK ╮", + "╭ MONITOR ╮": "╭ SEIRE ╮", + "╭ MONITOR ●╮": "╭ SEIRE ●╮", + "╭ PIE ╮": "╭ SEKTOR ╮", + "╭ QUOTA ╮": "╭ KVOOT ╮", + "╭ USAGE ╮": "╭ KASUTUS ╮", + "╭PIE╮": "╭SEKTOR╮", + "Sun": "P", + "Mon": "E", + "Tue": "T", + "Wed": "K", + "Thu": "N", + "Fri": "R", + "Sat": "L", + "Jan": "jaan", + "Feb": "veebr", + "Mar": "märts", + "Apr": "apr", + "May": "mai", + "Jun": "juuni", + "Jul": "juuli", + "Aug": "aug", + "Sep": "sept", + "Oct": "okt", + "Nov": "nov", + "Dec": "dets", + " LOOP": " TSÜKKEL", + "[ (C) COPY ]": "[ (C) KOPEERI ]", + "[ (X) CLOSE ]": "[ (X) SULGE ]", + "[ (L) CLEAR ALL ]": "[ (L) TÜHJENDA KÕIK ]", + "[ (X) CANCEL ]": "[ (X) TÜHISTA ]", + "[ (D) DONE ]": "[ (D) VALMIS ]", + "PRICES RETRIEVED ": "HINNAD HANGITUD " +} diff --git a/internal/i18n/locales/fi.json b/internal/i18n/locales/fi.json new file mode 100644 index 0000000..85e7416 --- /dev/null +++ b/internal/i18n/locales/fi.json @@ -0,0 +1,363 @@ +{ + "[ H: HIDE DETAIL ]": "[ H: PIILOTA TIEDOT ]", + "[ H: SHOW DETAIL ]": "[ H: NÄYTÄ TIEDOT ]", + "This approval action must be handled in Codex.": "Tämä hyväksyntä on käsiteltävä Codexissa.", + "JUSTIFICATION": "PERUSTELU", + "COMMAND": "KOMENTO", + "WORKING DIRECTORY": "TYÖHAKEMISTO", + "PERSISTENT PERMISSION RULE": "PYSYVÄ KÄYTTÖOIKEUSSÄÄNTÖ", + "FOLLOW-UP": "JATKOKYSYMYS", + "REPLY": "VASTAUS", + "[ Click here or press Enter to write ]": "[ Kirjoita napsauttamalla tästä tai painamalla Enter ]", + "Enter: send / next answer • Esc: leave editor • ↑/↓: choices": "Enter: lähetä / seuraava vastaus • Esc: poistu editorista • ↑/↓: valinnat", + "Sending…": "Lähetetään…", + "Text sent ...": "Teksti lähetetty ...", + "Send unconfirmed; check Codex before retrying.": "Lähetystä ei vahvistettu; tarkista Codex ennen uutta yritystä.", + "Prompt changed; review the session before replying.": "Kehote muuttui; tarkista istunto ennen vastaamista.", + "Choose an offered answer with ↑/↓.": "Valitse tarjottu vastaus näppäimillä ↑/↓.", + "OPEN DETAIL": "AVAA TIEDOT", + "No supported approval choices were offered.": "Tuettuja hyväksyntävaihtoehtoja ei tarjottu.", + "[ ALLOW FOR SESSION ]": "[ SALLI ISTUNNON AJAKSI ]", + "[ CONFIRM SESSION GRANT ]": "[ VAHVISTA ISTUNNON LUPA ]", + "[ ALWAYS ALLOW PREFIX ]": "[ SALLI ETULIITE AINA ]", + "[ CONFIRM PERSISTENT RULE ]": "[ VAHVISTA PYSYVÄ SÄÄNTÖ ]", + "[ REJECT & STOP TURN ]": "[ HYLKÄÄ JA PYSÄYTÄ VUORO ]", + "OFFERED DECISIONS": "TARJOTUT PÄÄTÖKSET", + "Questions must be answered in Codex.": "Kysymyksiin on vastattava Codexissa.", + "Local observation cannot answer live approvals.": "Paikallinen seuranta ei voi vastata reaaliaikaisiin hyväksyntöihin.", + "Network approval is not supported here.": "Verkkokäytön hyväksyntää ei tueta tässä.", + "Additional permissions require approval in Codex.": "Lisäoikeudet on hyväksyttävä Codexissa.", + "File-change approval is not supported here.": "Tiedostomuutosten hyväksyntää ei tueta tässä.", + "Unsupported command format.": "Komentomuotoa ei tueta.", + "Command details are missing.": "Komennon tiedot puuttuvat.", + "Working directory is missing.": "Työhakemisto puuttuu.", + "Turn or item identity is missing.": "Vuoron tai kohteen tunniste puuttuu.", + "One-time approval and decline are not both offered.": "Kertahyväksyntää ja hylkäystä ei tarjota molempia.", + "Request details exceed the display limit.": "Pyynnön tiedot ylittävät näyttörajan.", + "Request text was altered for safe display.": "Pyynnön tekstiä muutettiin turvallista näyttöä varten.", + "No actionable live approval was received.": "Käsiteltävää reaaliaikaista hyväksyntää ei vastaanotettu.", + "Request resolved, expired, or disconnected.": "Pyyntö on ratkaistu, vanhentunut tai yhteys katkesi.", + "Enlarge the terminal to show approval controls.": "Suurenna päätettä nähdäksesi hyväksyntäpainikkeet.", + "[ APPROVE ONCE ]": "[ HYVÄKSY KERRAN ]", + "[ CONFIRM APPROVAL ]": "[ VAHVISTA HYVÄKSYNTÄ ]", + "[ DECLINE ]": "[ HYLKÄÄ ]", + "Sending decision…": "Lähetetään päätöstä…", + "Decision sent ...": "Päätös lähetetty ...", + "Decision unconfirmed; check Codex. Do not retry here.": "Päätöstä ei vahvistettu; tarkista Codex. Älä yritä uudelleen tässä.", + "LAST REPLY": "VIIMEISIN VASTAUS", + "QUESTION": "KYSYMYS", + "REQUEST": "PYYNTÖ", + "LAST ACTIVITY": "VIIMEISIN TOIMINTA", + "[H:SHOW]": "[H:NÄYTÄ]", + "[H:HIDE]": "[H:PIILOTA]", + "NO CONTEXT": "EI KONTEKSTIA", + "REPLY IN CODEX": "VASTAA CODEXISSA", + "SESSION CONTEXT": "ISTUNNON KONTEKSTI", + "TURN COMPLETE": "VUORO VALMIS", + "OBSERVED API-EQ // SPEND ~%s // 100%% ~%s // %s · N=%d": "HAVAITTU API-EQ // KULUTUS ~%s // 100%% ~%s // %s · N=%d", + "MED": "KESKI", + "RESULT MATRIX": "TULOSTAULUKKO", + "\nBENCHMARK TRANSCRIPT: UNAVAILABLE\n": "\nTESTIN LOKI: EI SAATAVILLA\n", + " // CREDITS ": " // KREDITIT ", + " // CREDITS AVAILABLE": " // KREDITTEJÄ SAATAVILLA", + " // CREDITS UNLIMITED": " // RAJATTOMAT KREDITIT", + " // RESET TOKENS %d": " // NOLLAUSTOKENIT %d", + " // EFFORT // ": " // TASO // ", + " // LOCAL STALE": " // PAIKALLINEN VANHENTUNUT", + " // NO INTEGER Δ": " // EI KOKONAISLUVUN Δ:AA", + " // PEAK ": " // HUIPPU ", + " // PUBLISHED CONDITION WITH HIGH": " // JULKAISTU EHTO HIGH-TASOLLA", + " // R REFRESH": " // R PÄIVITÄ", + " // R RETRY": " // R YRITÄ UUDELLEEN", + " // RESET": " // NOLLAA", + " // SCALE ": " // ASTEIKKO ", + " // SPACE TOGGLE // ESC CANCEL": " // VÄLILYÖNTI VAIHDA // ESC PERUUTA", + " // STALE": " // VANHENTUNUT", + " // TIME // ": " // AIKA // ", + " DAYS": " PÄIVÄÄ", + " HOURS": " TUNTIA", + " MINUTES": " MINUUTTIA", + " MODELS // ": " MALLIT // ", + " ONLINE // ": " YHTEYDESSÄ // ", + " RANK //": " SIJA //", + " REASONING LEVELS // ": " PÄÄTTELYTASOT // ", + " TOKENS": " TOKENIT", + " WEEKS": " VIIKKOA", + "%s // %d WEEKS": "%s // %d VIIKKOA", + "%s // BENCHMARK-ONLY // LINES %d-%d/%d": "%s // VAIN TESTI // RIVIT %d-%d/%d", + "%s QUOTA TELEMETRY CONSOLE · VERSION %s": "%s KIINTIÖTELEMETRIAKONSOLI · VERSIO %s", + "%s TOKENS // %.0f%% LOCAL": "%s TOKENIA // %.0f%% PAIKALLINEN", + "(P)AUSE": "(P) TAUKO", + "ACCOUNT": "TILI", + "ACCOUNT // ": "TILI // ", + "ACCOUNT HISTORY // UTC // %d WEEKS // R REFRESH": "TILIHISTORIA // UTC // %d VIIKKOA // R PÄIVITÄ", + "ACCOUNT HISTORY // UTC // UPDATED ": "TILIHISTORIA // UTC // PÄIVITETTY ", + "ACCOUNT QUOTA Δ": "TILIN KIINTIÖ Δ", + "ACCOUNT QUOTA Δ // NO WINDOWS": "TILIN KIINTIÖ Δ // EI JAKSOJA", + "ACCOUNT QUOTA Δ // UNAVAILABLE": "TILIN KIINTIÖ Δ // EI SAATAVILLA", + "ACQUIRING SIGNAL": "HAETAAN SIGNAALIA", + "ACTIVE": "AKTIIVINEN", + "ACTUAL MODEL: %s\n": "TODELLINEN MALLI: %s\n", + "ALGORITHM TRIAL": "ALGORITMITESTI", + "API-EQ // BENCHMARK ACTIVE": "API-EQ // TESTI AKTIIVINEN", + "API-EQ // BENCHMARK SETTLING": "API-EQ // TESTI TASAANTUU", + "API-EQ DEFERRED // BENCHMARK ACCOUNTING SETTLING": "API-EQ LYKÄTTY // TESTIN LASKENTA TASAANTUU", + "API-EQ DEFERRED // SUBSCRIPTION BENCHMARK ACTIVE": "API-EQ LYKÄTTY // TILAUSTESTI KÄYNNISSÄ", + "API-EQ LEARNING %d/%dPP": "API-EQ OPPII %d/%dPP", + "API-EQ LEARNING // ": "API-EQ OPPII // ", + "API-EQ LEARNING // %d/%dPP CLEAN MOVEMENT": "API-EQ OPPII // %d/%dPP PUHDAS MUUTOS", + "API-EQ LEARNING // %d/%dPP CLEAN MOVEMENT // RESTARTED: %s": "API-EQ OPPII // %d/%dPP PUHDAS MUUTOS // KÄYNNISTETTY UUDELLEEN: %s", + "API-EQ N/A // ACCOUNT ATTRIBUTION UNKNOWN": "API-EQ N/A // TILIKOHDISTUS TUNTEMATON", + "API-EQ N/A // LIMIT ATTRIBUTION UNKNOWN": "API-EQ N/A // RAJAKOHDISTUS TUNTEMATON", + "API-EQ NOW ~%s // 100%% ~%s · %s%d": "API-EQ NYT ~%s // 100%% ~%s · %s%d", + "APPROVAL": "HYVÄKSYNTÄ", + "APPROVAL NEEDED": "HYVÄKSYNTÄ TARVITAAN", + "AUTO-SCAN %s": "AUTOMAATTISKANNAUS %s", + "BALANCED +0 POINTS // ON PACE": "TASAPAINO +0 PISTETTÄ // TAHDISSA", + "BARS": "PYLVÄÄT", + "BENCHMARK": "TESTI", + "BENCHMARK // TERMINAL TOO SHORT": "TESTI // PÄÄTE LIIAN MATALA", + "BENCHMARK CONTROLS": "TESTIN OHJAUS", + "BENCHMARK FAULT": "TESTIVIRHE", + "BENCHMARK SCOPE // %d MODELS // %d EFFORTS // %d PAIRS": "TESTIN RAJAUS // %d MALLIA // %d TASOA // %d PARIA", + "BENCHMARK TRANSCRIPT // UNAVAILABLE": "TESTIN LOKI // EI SAATAVILLA", + "BRAILLE PIE": "BRAILLE-PIIRAKKA", + "CALLS %d // LAST %s": "KUTSUT %d // VIIMEISIN %s", + "CHECK": "TARKISTA", + "CHECK ALL": "VALITSE KAIKKI", + "CHECK SESSION": "TARKISTA ISTUNTO", + "CLEAR": "VAPAA", + "CLEAR ALL": "TYHJENNÄ KAIKKI", + "CODEXOMETER BENCHMARK RUN DETAIL\n": "CODEXOMETER TESTIN TIEDOT\n", + "COMPLETE // %d/%d PASS": "VALMIS // %d/%d HYVÄKSYTTY", + "COMPLETED RESULTS RETAINED // PRESS B TO RUN AGAIN": "VALMIIT TULOKSET SÄILYTETTY // AJA UUDELLEEN PAINAMALLA B", + "CONSUMPTION PACE": "KULUTUSTAHTI", + "CONSUMPTION PACE // TIME - USAGE": "KULUTUSTAHTI // AIKA - KÄYTTÖ", + "CREDITS": "KREDITIT", + "CUMULATIVE TOKENS": "KUMULATIIVISET TOKENIT", + "CYCLE START UNAVAILABLE // ": "JAKSON ALKU EI SAATAVILLA // ", + "DAILY ACTIVITY // %d WEEKS // UTC": "PÄIVITTÄINEN TOIMINTA // %d VIIKKOA // UTC", + "DAILY TOKENS": "PÄIVITTÄISET TOKENIT", + "DAYS": "PÄIVÄÄ", + "DEFICIT %+d POINTS // OVER PACE": "ALIJÄÄMÄ %+d PISTETTÄ // YLI TAHDIN", + "DISCOVERING MODELS": "ETSITÄÄN MALLEJA", + "DISCOVERING VISIBLE MODELS + EFFORTS": "ETSITÄÄN NÄKYVIÄ MALLEJA JA TASOJA", + "EFFORT": "TASO", + "EFFORT: %s\n": "TASO: %s\n", + "ELAPSED %s // RATE %s/MIN": "KULUNUT %s // NOPEUS %s/MIN", + "EQ // BENCH ACTIVE": "EQ // TESTI KÄYNNISSÄ", + "EQ NOW ~%s // FULL ~%s %s%d": "EQ NYT ~%s // TÄYSI ~%s %s%d", + "EST LOCAL-ONLY ": "VAIN PAIKALLINEN ARVIO ", + "ESTABLISHING A FRESH BASELINE": "LUODAAN UUTTA LÄHTÖTASOA", + "EXHAUSTED": "LOPPU", + "EXTERNAL DIGBENCH // PERSISTED REMOTE SESSION // RANDOM SEED": "ULKOINEN DIGBENCH // TALLENNETTU ETÄISTUNTO // SATUNNAISSIEMEN", + "Enlarge terminal for daily activity grid (7 weekday rows).": "Suurenna päätettä päiväruudukkoa varten (7 viikonpäiväriviä).", + "FAIL": "HYLÄTTY", + "FAILURE": "VIRHE", + "FAILURE // ": "VIRHE // ", + "FETCHING ACCOUNT HISTORY…": "HAETAAN TILIHISTORIAA…", + "FREE %3d%%": "VAPAA %3d%%", + "FRESH // GO!": "UUSI ALKU // MENOKSI!", + "FUEL TANK": "POLTTOAINESÄILIÖ", + "HANDSHAKE WITH CODEX APP-SERVER IN PROGRESS": "MUODOSTETAAN YHTEYTTÄ CODEX APP-SERVERIIN", + "HEADROOM %+d POINTS // UNDER PACE": "PELIVARA %+d PISTETTÄ // ALLE TAHDIN", + "HOURS": "TUNTIA", + "IDLE": "JOUTILAANA", + "IN PROGRESS": "KÄYNNISSÄ", + "IN PROGRESS (LVL %d)": "KÄYNNISSÄ (TASO %d)", + "INCOMPLETE": "KESKENERÄINEN", + "INITIALIZING LOCAL SESSION MONITOR": "ALUSTETAAN PAIKALLISTA ISTUNTOSEURANTAA", + "INPUT": "SYÖTE", + "INPUT NEEDED": "SYÖTETTÄ TARVITAAN", + "INTERRUPTING CURRENT BENCHMARK TRIAL": "KESKEYTETÄÄN NYKYINEN TESTI", + "LAST // ": "VIIMEISIN // ", + "LAST FAIL // ": "VIIMEISIN HYLÄTTY // ", + "LAST N/A // ": "VIIMEISIN N/A // ", + "LAST OUT ": "VIIMEISIN TULOSTE ", + "LESS ": "VÄHEMMÄN ", + "LIFETIME // %s TOKENS PEAK DAY // %s STREAK // %s DAYS": "KOKONAISAIKA // %s TOKENIA HUIPPUPÄIVÄ // %s PUTKI // %s PÄIVÄÄ", + "LIMIT NEAR": "RAJA LÄHELLÄ", + "LINEAR PROJECTION // LIMIT IN ~%s // %s EARLY": "LINEAARINEN ENNUSTE // RAJA ~%s KULUTTUA // %s ETUAJASSA", + "LINEAR PROJECTION // SAFE THROUGH RESET // ~%d%% LEFT": "LINEAARINEN ENNUSTE // RIITTÄÄ NOLLAUKSEEN // ~%d%% JÄLJELLÄ", + "LIVE LOCAL SESSIONS %d // P TO PAUSE": "PAIKALLISET LIVEISTUNNOT %d // P TAUOTTAA", + "LIVE RUN DETAIL // IN PROGRESS": "KÄYNNISSÄ OLEVAN TESTIN TIEDOT // KÄYNNISSÄ", + "LOCAL SESSIONS %d // LAST %s AGO": "PAIKALLISET ISTUNNOT %d // VIIMEKSI %s SITTEN", + "LOCAL SESSIONS %d // P TO RESUME": "PAIKALLISET ISTUNNOT %d // P JATKAA", + "LOCAL TELEMETRY UNAVAILABLE": "PAIKALLINEN TELEMETRIA EI SAATAVILLA", + "LOCAL TOKEN BARS": "PAIKALLISET TOKENPYLVÄÄT", + "LOW": "MATALA", + "MINUTES": "MINUUTTIA", + "MODEL": "MALLI", + "MODEL // ": "MALLI // ", + "MODEL: %s\n": "MALLI: %s\n", + "MONITOR": "SEURANTA", + "MONITOR READOUT": "SEURANTATIEDOT", + "MONITORING ●": "SEURATAAN ●", + "MONTHLY": "KUUKAUSITTAIN", + "MORE // PEAK DAY %s": "ENEMMÄN // HUIPPUPÄIVÄ %s", + "NEAR": "LÄHELLÄ", + "NO COMPLETE 30 SEC SAMPLE": "EI TÄYTTÄ 30 SEKUNNIN NÄYTETTÄ", + "NO RESULTS MATCH THE ACTIVE FILTER": "EI AKTIIVISTA SUODATINTA VASTAAVIA TULOKSIA", + "NO TOKEN SIGNAL": "EI TOKENSIGNAALIA", + "OBSERVATION DEFERRED": "HAVAINNOINTIA LYKÄTTY", + "PACE DATA UNAVAILABLE": "TAHTITIEDOT EI SAATAVILLA", + "PASS": "HYVÄKSYTTY", + "PAUSED": "TAUOLLA", + "PAUSING": "SIIRRYTÄÄN TAUOLLE", + "PIE": "PIIRAKKA", + "PRESS B OR CLICK TO RUN AGAIN": "AJA UUDELLEEN PAINAMALLA B TAI NAPSAUTTAMALLA", + "QUOTA": "KIINTIÖ", + "QUOTA CLEAR": "KIINTIÖ VAPAA", + "QUOTA EXHAUSTED": "KIINTIÖ LOPPU", + "QUOTA RESET": "KIINTIÖN NOLLAUS", + "QUOTA TELEMETRY // VERSION ": "KIINTIÖTELEMETRIA // VERSIO ", + "QUOTA UNKNOWN": "KIINTIÖ TUNTEMATON", + "QUOTA WATCH": "SEURAA KIINTIÖTÄ", + "RANK": "SIJA", + "RATE ": "NOPEUS ", + "RE(S)ET": "NOLLAA (S)", + "READING FINAL APPENDED TOKEN TELEMETRY": "LUETAAN VIIMEISTÄ LISÄTTYÄ TOKENTELEMETRIAA", + "READY": "VALMIS", + "REBASING AFTER PAUSED ACTIVITY": "PÄIVITETÄÄN LÄHTÖTASOA TAUON JÄLKEEN", + "REMOTE SESSIONS": "ETÄISTUNNOT", + "RESET": "NOLLAA", + "RESET CYCLE %3d%%%s // %s": "NOLLAUSJAKSO %3d%%%s // %s", + "RESET CYCLE // RESET DATA UNAVAILABLE": "NOLLAUSJAKSO // NOLLAUSTIEDOT EI SAATAVILLA", + "RESET DATA UNAVAILABLE": "NOLLAUSTIEDOT EI SAATAVILLA", + "RESET FRESH // GO!": "UUSI KIINTIÖ // MENOKSI!", + "RESET T-%s": "NOLLAA T-%s", + "RESET T-%s // %s": "NOLLAA T-%s // %s", + "RESETTING": "NOLLATAAN", + "RESULT": "TULOS", + "RESULT // ": "TULOS // ", + "RESULT MATRIX // STANDARD API-EQUIVALENT USD": "TULOSTAULUKKO // VAKIOHINTAINEN API-VASTINE USD", + "RESULT: %s\n": "TULOS: %s\n", + "RESUME (P)": "JATKA (P)", + "RESUMING": "JATKETAAN", + "ROWS %d-%d/%d": "RIVIT %d-%d/%d", + "ROWS %d-%d/%d // ↑ ↓ SELECT // ENTER OR CLICK DETAILS": "RIVIT %d-%d/%d // ↑ ↓ VALITSE // TIEDOT: ENTER TAI NAPSAUTUS", + "RUN DETAIL": "TESTIN TIEDOT", + "RUN SCOPE OR RUN ALL TO BEGIN // THIS CONSUMES CODEX QUOTA": "ALOITA AJAMALLA VALITUT TAI KAIKKI // KULUTTAA CODEX-KIINTIÖTÄ", + "RUNNING %d/%d": "AJETAAN %d/%d", + "SAMPLES %d // NEXT %s": "NÄYTTEET %d // SEURAAVA %s", + "SCANNING": "SKANNATAAN", + "SCANNING LOCAL CODEX TELEMETRY": "SKANNATAAN PAIKALLISTA CODEX-TELEMETRIAA", + "SESSION // ": "ISTUNTO // ", + "SHOW": "NÄYTÄ", + "SHOW //": "NÄYTÄ //", + "SIGNAL FAULT": "SIGNAALIVIRHE", + "STALE": "VANHENTUNUT", + "STALE // ": "VANHENTUNUT // ", + "STALE SIGNAL": "VANHENTUNUT SIGNAALI", + "START %s // NOW %s": "ALKU %s // NYT %s", + "STARTING": "KÄYNNISTETÄÄN", + "STARTING MONITOR": "KÄYNNISTETÄÄN SEURANTAA", + "STOP ISSUE": "PYSÄYTYSVIRHE", + "STOP ISSUE // ": "PYSÄYTYSVIRHE // ", + "STOPPED": "PYSÄYTETTY", + "STOPPED // %d/%d COMPLETE": "PYSÄYTETTY // %d/%d VALMIS", + "STOPPING // %d/%d COMPLETE": "PYSÄYTETÄÄN // %d/%d VALMIS", + "TASK": "TEHTÄVÄ", + "TASK // ": "TEHTÄVÄ // ", + "TASK: %s\n": "TEHTÄVÄ: %s\n", + "THEME // ": "TEEMA // ", + "THEME // %s": "TEEMA // %s", + "TIME": "AIKA", + "TIME: %s\n": "AIKA: %s\n", + "TOKEN BARS": "TOKENPYLVÄÄT", + "TOKENS": "TOKENIT", + "TOKENS // ": "TOKENIT // ", + "TOKENS // N/A": "TOKENIT // N/A", + "TOKENS: %s\n": "TOKENIT: %s\n", + "TOKENS: N/A\n": "TOKENIT: N/A\n", + "TOTAL %d // INPUT %d // CACHED %d // OUTPUT %d // REASONING %d": "YHTEENSÄ %d // SYÖTE %d // VÄLIMUISTISSA %d // TULOSTE %d // PÄÄTTELY %d", + "TRANSCRIPT: BENCHMARK-ONLY\n": "LOKI: VAIN TESTI\n", + "TURNS": "VUOROT", + "Token activity history unavailable. Requires a supported Codex CLI and ChatGPT login.": "Tokenien käyttöhistoria ei ole saatavilla. Vaatii tuetun Codex CLI:n ja ChatGPT-kirjautumisen.", + "UNAVAILABLE // ": "EI SAATAVILLA // ", + "UNKNOWN": "TUNTEMATON", + "UNKNOWN PLAN": "TUNTEMATON TILAUS", + "USAGE": "KÄYTTÖ", + "USED %3d%%": "KÄYTETTY %3d%%", + "USES QUOTA // %d SELECTED MODEL + EFFORT PAIRS": "KULUTTAA KIINTIÖTÄ // %d VALITTUA MALLI- JA TASOPARIA", + "Use one reset? Refreshes eligible quota and changes the weekly reset schedule. Click CONFIRM; Esc cancels.": "Käytetäänkö yksi nollaus? Uudistaa soveltuvan kiintiön ja muuttaa viikoittaista nollausaikaa. Napsauta VAHVISTA; Esc peruuttaa.", + "WAITING FOR FIRST RESULT": "ODOTETAAN ENSIMMÄISTÄ TULOSTA", + "WAITING FOR FIRST SAMPLE": "ODOTETAAN ENSIMMÄISTÄ NÄYTETTÄ", + "WATCH": "SEURAA", + "WEEKLY TOKENS": "VIIKOITTAISET TOKENIT", + "WEEKS": "VIIKKOA", + "[ (1)2 MONTHS ]": "[ (1)2 KUUKAUTTA ]", + "[ (6) MONTHS ]": "[ (6) KUUKAUTTA ]", + "[ (A) RUN ALL // %d ]": "[ (A) AJA KAIKKI // %d ]", + "[ (B) RUN SCOPE // %d REMOTE ]": "[ (B) AJA VALITUT // %d ETÄ ]", + "[ (B) RUN SCOPE // %d ]": "[ (B) AJA VALITUT // %d ]", + "[ (C)UMULATIVE ]": "[ (C) KUMULATIIVINEN ]", + "[ (D)AILY ]": "[ (D) PÄIVITTÄIN ]", + "[ (Q)UIT ]": "[ (Q) LOPETA ]", + "[ (R)EFRESH ]": "[ (R) PÄIVITÄ ]", + "[ (S) SCOPE // %d ]": "[ (S) RAJAUS // %d ]", + "[ (T)HEME ]": "[ (T) TEEMA ]", + "[ (V)IEW ]": "[ (V) NÄKYMÄ ]", + "[ (W)EEKLY ]": "[ (W) VIIKOITTAIN ]", + "[ (X) STOP ]": "[ (X) PYSÄYTÄ ]", + "[ ALL ]": "[ KAIKKI ]", + "[ CONFIRM // %d %s ]": "[ VAHVISTA // %d %s ]", + "[ CONFIRM // %d REMOTE SESSIONS ]": "[ VAHVISTA // %d ETÄISTUNTOA ]", + "[ CONFIRM RESET ]": "[ VAHVISTA NOLLAUS ]", + "[ COST ]": "[ HINTA ]", + "[ DISCOVERING TURNS… ]": "[ ETSITÄÄN VUOROJA… ]", + "[ FAIL ]": "[ HYLÄTTY ]", + "[ NEWER → ]": "[ UUDEMMAT → ]", + "[ PASS ]": "[ HYVÄKSYTTY ]", + "[ RESET // %d ]": "[ NOLLAA // %d ]", + "[ RESETTING… ]": "[ NOLLATAAN… ]", + "[ RETRY RESET ]": "[ YRITÄ NOLLAUSTA UUDELLEEN ]", + "[ SPEED ]": "[ NOPEUS ]", + "[ ← OLDER ]": "[ ← VANHEMMAT ]", + "[A:CONFIRM %d]": "[A:VAHVISTA %d]", + "[ALL:%d]": "[KAIKKI:%d]", + "[ALL]": "[KAIKKI]", + "[B:CONFIRM %d]": "[B:VAHVISTA %d]", + "[B:SCOPE %d]": "[B:RAJAUS %d]", + "[FAIL]": "[HYLÄTTY]", + "[PASS]": "[HYVÄKSYTTY]", + "[S:SCOPE %d]": "[S:RAJAUS %d]", + "[SCOPE:%d]": "[RAJAUS:%d]", + "[STOP]": "[PYSÄYTÄ]", + "╭ BARS ╮": "╭ PYLVÄÄT ╮", + "╭ BENCHMARK ╮": "╭ TESTI ╮", + "╭ CONSUMPTION PACE ╮": "╭ KULUTUSTAHTI ╮", + "╭ FUEL TANK ╮": "╭ POLTTOAINESÄILIÖ ╮", + "╭ MONITOR ╮": "╭ SEURANTA ╮", + "╭ MONITOR ●╮": "╭ SEURANTA ●╮", + "╭ PIE ╮": "╭ PIIRAKKA ╮", + "╭ QUOTA ╮": "╭ KIINTIÖ ╮", + "╭ USAGE ╮": "╭ KÄYTTÖ ╮", + "╭PIE╮": "╭PIIRAKKA╮", + "Sun": "su", + "Mon": "ma", + "Tue": "ti", + "Wed": "ke", + "Thu": "to", + "Fri": "pe", + "Sat": "la", + "Jan": "tammi", + "Feb": "helmi", + "Mar": "maalis", + "Apr": "huhti", + "May": "touko", + "Jun": "kesä", + "Jul": "heinä", + "Aug": "elo", + "Sep": "syys", + "Oct": "loka", + "Nov": "marras", + "Dec": "joulu", + " LOOP": " SILMUKKA", + "[ (C) COPY ]": "[ (C) KOPIOI ]", + "[ (X) CLOSE ]": "[ (X) SULJE ]", + "[ (L) CLEAR ALL ]": "[ (L) TYHJENNÄ KAIKKI ]", + "[ (X) CANCEL ]": "[ (X) PERUUTA ]", + "[ (D) DONE ]": "[ (D) VALMIS ]", + "PRICES RETRIEVED ": "HINNAT HAETTU " +} diff --git a/internal/i18n/locales/nb.json b/internal/i18n/locales/nb.json new file mode 100644 index 0000000..cee4e6d --- /dev/null +++ b/internal/i18n/locales/nb.json @@ -0,0 +1,363 @@ +{ + "[ H: HIDE DETAIL ]": "[ H: SKJUL DETALJER ]", + "[ H: SHOW DETAIL ]": "[ H: VIS DETALJER ]", + "This approval action must be handled in Codex.": "Denne handlingen må godkjennes i Codex.", + "JUSTIFICATION": "BEGRUNNELSE", + "COMMAND": "KOMMANDO", + "WORKING DIRECTORY": "ARBEIDSMAPPE", + "PERSISTENT PERMISSION RULE": "VARIG TILLATELSESREGEL", + "FOLLOW-UP": "OPPFØLGING", + "REPLY": "SVAR", + "[ Click here or press Enter to write ]": "[ Klikk her eller trykk Enter for å skrive ]", + "Enter: send / next answer • Esc: leave editor • ↑/↓: choices": "Enter: send / neste svar • Esc: avslutt inntasting • ↑/↓: valg", + "Sending…": "Sender…", + "Text sent ...": "Tekst sendt ...", + "Send unconfirmed; check Codex before retrying.": "Sending ubekreftet; sjekk Codex før du prøver igjen.", + "Prompt changed; review the session before replying.": "Spørsmålet er endret; sjekk økten før du svarer.", + "Choose an offered answer with ↑/↓.": "Velg et av svarene med ↑/↓.", + "OPEN DETAIL": "ÅPNE DETALJER", + "No supported approval choices were offered.": "Ingen støttede godkjenningsvalg ble tilbudt.", + "[ ALLOW FOR SESSION ]": "[ TILLAT FOR ØKTEN ]", + "[ CONFIRM SESSION GRANT ]": "[ BEKREFT ØKTTILLATELSE ]", + "[ ALWAYS ALLOW PREFIX ]": "[ TILLAT ALLTID PREFIKS ]", + "[ CONFIRM PERSISTENT RULE ]": "[ BEKREFT VARIG REGEL ]", + "[ REJECT & STOP TURN ]": "[ AVVIS & STOPP TUREN ]", + "OFFERED DECISIONS": "TILBUDTE VALG", + "Questions must be answered in Codex.": "Spørsmål må besvares i Codex.", + "Local observation cannot answer live approvals.": "Lokal avlesning kan ikke besvare sanntidsgodkjenninger.", + "Network approval is not supported here.": "Nettverksgodkjenning støttes ikke her.", + "Additional permissions require approval in Codex.": "Ekstra tillatelser må godkjennes i Codex.", + "File-change approval is not supported here.": "Godkjenning av filendringer støttes ikke her.", + "Unsupported command format.": "Kommandoformatet støttes ikke.", + "Command details are missing.": "Kommandoopplysninger mangler.", + "Working directory is missing.": "Arbeidsmappe mangler.", + "Turn or item identity is missing.": "Turens eller elementets ID mangler.", + "One-time approval and decline are not both offered.": "Engangsgodkjenning og avslag tilbys ikke begge.", + "Request details exceed the display limit.": "Forespørselens detaljer overskrider visningsgrensen.", + "Request text was altered for safe display.": "Forespørselsteksten ble endret for sikker visning.", + "No actionable live approval was received.": "Ingen håndterbar sanntidsgodkjenning ble mottatt.", + "Request resolved, expired, or disconnected.": "Forespørselen er løst, utløpt eller frakoblet.", + "Enlarge the terminal to show approval controls.": "Utvid terminalen for å vise godkjenningsknappene.", + "[ APPROVE ONCE ]": "[ GODKJENN ÉN GANG ]", + "[ CONFIRM APPROVAL ]": "[ BEKREFT GODKJENNING ]", + "[ DECLINE ]": "[ AVVIS ]", + "Sending decision…": "Sender beslutning…", + "Decision sent ...": "Beslutning sendt ...", + "Decision unconfirmed; check Codex. Do not retry here.": "Beslutning ubekreftet; sjekk Codex. Ikke prøv igjen her.", + "LAST REPLY": "SISTE SVAR", + "QUESTION": "SPØRSMÅL", + "REQUEST": "FORESPØRSEL", + "LAST ACTIVITY": "SISTE AKTIVITET", + "[H:SHOW]": "[H:VIS]", + "[H:HIDE]": "[H:SKJUL]", + "NO CONTEXT": "INGEN KONTEKST", + "REPLY IN CODEX": "SVAR I CODEX", + "SESSION CONTEXT": "ØKTKONTEKST", + "TURN COMPLETE": "TUR FULLFØRT", + "OBSERVED API-EQ // SPEND ~%s // 100%% ~%s // %s · N=%d": "OBSERVERT API-EQ // FORBRUK ~%s // 100%% ~%s // %s · N=%d", + "MED": "MIDDELS", + "RESULT MATRIX": "RESULTATMATRISE", + "\nBENCHMARK TRANSCRIPT: UNAVAILABLE\n": "\nTESTUTSKRIFT: UTILGJENGELIG\n", + " // CREDITS ": " // KREDITTER ", + " // CREDITS AVAILABLE": " // KREDITTER TILGJENGELIG", + " // CREDITS UNLIMITED": " // UBEGRENSEDE KREDITTER", + " // RESET TOKENS %d": " // TILBAKESTILLINGSTOKEN %d", + " // EFFORT // ": " // NIVÅ // ", + " // LOCAL STALE": " // LOKALT UTDATERT", + " // NO INTEGER Δ": " // INGEN HELTALLS-Δ", + " // PEAK ": " // TOPP ", + " // PUBLISHED CONDITION WITH HIGH": " // PUBLISERT VILKÅR MED HIGH", + " // R REFRESH": " // R OPPDATER", + " // R RETRY": " // R PRØV IGJEN", + " // RESET": " // TILBAKESTILL", + " // SCALE ": " // SKALA ", + " // SPACE TOGGLE // ESC CANCEL": " // MELLOMROM BYTT // ESC AVBRYT", + " // STALE": " // UTDATERT", + " // TIME // ": " // TID // ", + " DAYS": " DAGER", + " HOURS": " TIMER", + " MINUTES": " MINUTTER", + " MODELS // ": " MODELLER // ", + " ONLINE // ": " TILKOBLET // ", + " RANK //": " RANGERING //", + " REASONING LEVELS // ": " RESONNERINGSNIVÅER // ", + " TOKENS": " TOKEN", + " WEEKS": " UKER", + "%s // %d WEEKS": "%s // %d UKER", + "%s // BENCHMARK-ONLY // LINES %d-%d/%d": "%s // KUN TEST // LINJER %d-%d/%d", + "%s QUOTA TELEMETRY CONSOLE · VERSION %s": "%s KONSOLL FOR KVOTETELEMETRI · VERSJON %s", + "%s TOKENS // %.0f%% LOCAL": "%s TOKEN // %.0f%% LOKALT", + "(P)AUSE": "(P)AUSE", + "ACCOUNT": "KONTO", + "ACCOUNT // ": "KONTO // ", + "ACCOUNT HISTORY // UTC // %d WEEKS // R REFRESH": "KONTOHISTORIKK // UTC // %d UKER // R OPPDATER", + "ACCOUNT HISTORY // UTC // UPDATED ": "KONTOHISTORIKK // UTC // OPPDATERT ", + "ACCOUNT QUOTA Δ": "KONTOKVOTE Δ", + "ACCOUNT QUOTA Δ // NO WINDOWS": "KONTOKVOTE Δ // INGEN PERIODER", + "ACCOUNT QUOTA Δ // UNAVAILABLE": "KONTOKVOTE Δ // UTILGJENGELIG", + "ACQUIRING SIGNAL": "SØKER SIGNAL", + "ACTIVE": "AKTIV", + "ACTUAL MODEL: %s\n": "FAKTISK MODELL: %s\n", + "ALGORITHM TRIAL": "ALGORITMETEST", + "API-EQ // BENCHMARK ACTIVE": "API-EQ // TEST AKTIV", + "API-EQ // BENCHMARK SETTLING": "API-EQ // TEST STABILISERES", + "API-EQ DEFERRED // BENCHMARK ACCOUNTING SETTLING": "API-EQ UTSATT // TESTREGNSKAP STABILISERES", + "API-EQ DEFERRED // SUBSCRIPTION BENCHMARK ACTIVE": "API-EQ UTSATT // ABONNEMENTSTEST AKTIV", + "API-EQ LEARNING %d/%dPP": "API-EQ LÆRER %d/%dPP", + "API-EQ LEARNING // ": "API-EQ LÆRER // ", + "API-EQ LEARNING // %d/%dPP CLEAN MOVEMENT": "API-EQ LÆRER // %d/%dPP REN ENDRING", + "API-EQ LEARNING // %d/%dPP CLEAN MOVEMENT // RESTARTED: %s": "API-EQ LÆRER // %d/%dPP REN ENDRING // STARTET PÅ NYTT: %s", + "API-EQ N/A // ACCOUNT ATTRIBUTION UNKNOWN": "API-EQ N/A // KONTOTILORDNING UKJENT", + "API-EQ N/A // LIMIT ATTRIBUTION UNKNOWN": "API-EQ N/A // GRENSETILORDNING UKJENT", + "API-EQ NOW ~%s // 100%% ~%s · %s%d": "API-EQ NÅ ~%s // 100%% ~%s · %s%d", + "APPROVAL": "GODKJENNING", + "APPROVAL NEEDED": "GODKJENNING KREVES", + "AUTO-SCAN %s": "AUTOSKANNING %s", + "BALANCED +0 POINTS // ON PACE": "BALANSE +0 POENG // I RUTE", + "BARS": "STOLPER", + "BENCHMARK": "TEST", + "BENCHMARK // TERMINAL TOO SHORT": "TEST // TERMINALEN ER FOR LAV", + "BENCHMARK CONTROLS": "TESTKONTROLLER", + "BENCHMARK FAULT": "TESTFEIL", + "BENCHMARK SCOPE // %d MODELS // %d EFFORTS // %d PAIRS": "TESTUTVALG // %d MODELLER // %d NIVÅER // %d PAR", + "BENCHMARK TRANSCRIPT // UNAVAILABLE": "TESTUTSKRIFT // UTILGJENGELIG", + "BRAILLE PIE": "BRAILLESEKTOR", + "CALLS %d // LAST %s": "KALL %d // SISTE %s", + "CHECK": "SJEKK", + "CHECK ALL": "VELG ALLE", + "CHECK SESSION": "SJEKK ØKTEN", + "CLEAR": "KLAR", + "CLEAR ALL": "TØM ALLE", + "CODEXOMETER BENCHMARK RUN DETAIL\n": "CODEXOMETER TESTDETALJER\n", + "COMPLETE // %d/%d PASS": "FULLFØRT // %d/%d BESTÅTT", + "COMPLETED RESULTS RETAINED // PRESS B TO RUN AGAIN": "FULLFØRTE RESULTATER BEHOLDT // TRYKK B FOR NY TEST", + "CONSUMPTION PACE": "FORBRUKSTAKT", + "CONSUMPTION PACE // TIME - USAGE": "FORBRUKSTAKT // TID - BRUK", + "CREDITS": "KREDITTER", + "CUMULATIVE TOKENS": "AKKUMULERTE TOKEN", + "CYCLE START UNAVAILABLE // ": "PERIODESTART UTILGJENGELIG // ", + "DAILY ACTIVITY // %d WEEKS // UTC": "DAGLIG AKTIVITET // %d UKER // UTC", + "DAILY TOKENS": "TOKEN PER DAG", + "DAYS": "DAGER", + "DEFICIT %+d POINTS // OVER PACE": "UNDERSKUDD %+d POENG // OVER TAKTEN", + "DISCOVERING MODELS": "FINNER MODELLER", + "DISCOVERING VISIBLE MODELS + EFFORTS": "FINNER SYNLIGE MODELLER + NIVÅER", + "EFFORT": "NIVÅ", + "EFFORT: %s\n": "NIVÅ: %s\n", + "ELAPSED %s // RATE %s/MIN": "FORLØPT %s // TAKT %s/MIN", + "EQ // BENCH ACTIVE": "EQ // TEST AKTIV", + "EQ NOW ~%s // FULL ~%s %s%d": "EQ NÅ ~%s // FULLT ~%s %s%d", + "EST LOCAL-ONLY ": "ANSLAG KUN LOKALT ", + "ESTABLISHING A FRESH BASELINE": "OPPRETTER NY GRUNNLINJE", + "EXHAUSTED": "OPPBRUKT", + "EXTERNAL DIGBENCH // PERSISTED REMOTE SESSION // RANDOM SEED": "EKSTERN DIGBENCH // LAGRET EKSTERN ØKT // TILFELDIG FRØ", + "Enlarge terminal for daily activity grid (7 weekday rows).": "Utvid terminalen for aktivitetsrutenettet (7 ukedagsrader).", + "FAIL": "IKKE BESTÅTT", + "FAILURE": "FEIL", + "FAILURE // ": "FEIL // ", + "FETCHING ACCOUNT HISTORY…": "HENTER KONTOHISTORIKK…", + "FREE %3d%%": "LEDIG %3d%%", + "FRESH // GO!": "NY START // KJØR!", + "FUEL TANK": "DRIVSTOFFTANK", + "HANDSHAKE WITH CODEX APP-SERVER IN PROGRESS": "TILKOBLING TIL CODEX APP-SERVER PÅGÅR", + "HEADROOM %+d POINTS // UNDER PACE": "MARGIN %+d POENG // UNDER TAKTEN", + "HOURS": "TIMER", + "IDLE": "INAKTIV", + "IN PROGRESS": "PÅGÅR", + "IN PROGRESS (LVL %d)": "PÅGÅR (NIVÅ %d)", + "INCOMPLETE": "UFULLSTENDIG", + "INITIALIZING LOCAL SESSION MONITOR": "INITIALISERER LOKAL ØKTOVERVÅKING", + "INPUT": "INNDATA", + "INPUT NEEDED": "INNDATA KREVES", + "INTERRUPTING CURRENT BENCHMARK TRIAL": "AVBRYTER PÅGÅENDE TEST", + "LAST // ": "SISTE // ", + "LAST FAIL // ": "SISTE IKKE BESTÅTT // ", + "LAST N/A // ": "SISTE N/A // ", + "LAST OUT ": "SISTE UTDATA ", + "LESS ": "MINDRE ", + "LIFETIME // %s TOKENS PEAK DAY // %s STREAK // %s DAYS": "TOTALT // %s TOKEN TOPPDAG // %s REKKE // %s DAGER", + "LIMIT NEAR": "NÆR GRENSEN", + "LINEAR PROJECTION // LIMIT IN ~%s // %s EARLY": "LINEÆR PROGNOSE // GRENSE OM ~%s // %s FOR TIDLIG", + "LINEAR PROJECTION // SAFE THROUGH RESET // ~%d%% LEFT": "LINEÆR PROGNOSE // HOLDER TIL TILBAKESTILLING // ~%d%% IGJEN", + "LIVE LOCAL SESSIONS %d // P TO PAUSE": "LOKALE SANNTIDSØKTER %d // P FOR PAUSE", + "LIVE RUN DETAIL // IN PROGRESS": "DETALJER FOR PÅGÅENDE TEST // PÅGÅR", + "LOCAL SESSIONS %d // LAST %s AGO": "LOKALE ØKTER %d // SIST FOR %s SIDEN", + "LOCAL SESSIONS %d // P TO RESUME": "LOKALE ØKTER %d // P FOR Å FORTSETTE", + "LOCAL TELEMETRY UNAVAILABLE": "LOKAL TELEMETRI UTILGJENGELIG", + "LOCAL TOKEN BARS": "LOKALE TOKENSTOLPER", + "LOW": "LAV", + "MINUTES": "MINUTTER", + "MODEL": "MODELL", + "MODEL // ": "MODELL // ", + "MODEL: %s\n": "MODELL: %s\n", + "MONITOR": "OVERVÅKING", + "MONITOR READOUT": "OVERVÅKINGSDATA", + "MONITORING ●": "OVERVÅKER ●", + "MONTHLY": "MÅNEDLIG", + "MORE // PEAK DAY %s": "MER // TOPPDAG %s", + "NEAR": "NÆR", + "NO COMPLETE 30 SEC SAMPLE": "INGEN HEL 30-SEKUNDERSMÅLING", + "NO RESULTS MATCH THE ACTIVE FILTER": "INGEN RESULTATER SAMSVARER MED FILTERET", + "NO TOKEN SIGNAL": "INGEN TOKENSIGNAL", + "OBSERVATION DEFERRED": "OBSERVASJON UTSATT", + "PACE DATA UNAVAILABLE": "TAKTDATA UTILGJENGELIG", + "PASS": "BESTÅTT", + "PAUSED": "PAUSET", + "PAUSING": "PAUSER", + "PIE": "SEKTOR", + "PRESS B OR CLICK TO RUN AGAIN": "TRYKK B ELLER KLIKK FOR NY TEST", + "QUOTA": "KVOTE", + "QUOTA CLEAR": "KVOTEN ER KLAR", + "QUOTA EXHAUSTED": "KVOTEN ER OPPBRUKT", + "QUOTA RESET": "KVOTETILBAKESTILLING", + "QUOTA TELEMETRY // VERSION ": "KVOTETELEMETRI // VERSJON ", + "QUOTA UNKNOWN": "UKJENT KVOTE", + "QUOTA WATCH": "FØLG MED PÅ KVOTEN", + "RANK": "RANGERING", + "RATE ": "TAKT ", + "RE(S)ET": "TILBAKESTILL (S)", + "READING FINAL APPENDED TOKEN TELEMETRY": "LESER SISTE TILFØYDE TOKENDATA", + "READY": "KLAR", + "REBASING AFTER PAUSED ACTIVITY": "JUSTERER GRUNNLINJE ETTER PAUSE", + "REMOTE SESSIONS": "EKSTERNE ØKTER", + "RESET": "TILBAKESTILL", + "RESET CYCLE %3d%%%s // %s": "TILBAKESTILLINGSPERIODE %3d%%%s // %s", + "RESET CYCLE // RESET DATA UNAVAILABLE": "TILBAKESTILLINGSPERIODE // TILBAKESTILLINGSDATA UTILGJENGELIG", + "RESET DATA UNAVAILABLE": "TILBAKESTILLINGSDATA UTILGJENGELIG", + "RESET FRESH // GO!": "NY KVOTE // KJØR!", + "RESET T-%s": "TILBAKESTILL T-%s", + "RESET T-%s // %s": "TILBAKESTILL T-%s // %s", + "RESETTING": "TILBAKESTILLER", + "RESULT": "RESULTAT", + "RESULT // ": "RESULTAT // ", + "RESULT MATRIX // STANDARD API-EQUIVALENT USD": "RESULTATMATRISE // STANDARD API-EKVIVALENT USD", + "RESULT: %s\n": "RESULTAT: %s\n", + "RESUME (P)": "FORTSETT (P)", + "RESUMING": "FORTSETTER", + "ROWS %d-%d/%d": "RADER %d-%d/%d", + "ROWS %d-%d/%d // ↑ ↓ SELECT // ENTER OR CLICK DETAILS": "RADER %d-%d/%d // ↑ ↓ VELG // ENTER ELLER KLIKK FOR DETALJER", + "RUN DETAIL": "TESTDETALJER", + "RUN SCOPE OR RUN ALL TO BEGIN // THIS CONSUMES CODEX QUOTA": "KJØR UTVALG ELLER ALLE FOR Å STARTE // BRUKER CODEX-KVOTE", + "RUNNING %d/%d": "KJØRER %d/%d", + "SAMPLES %d // NEXT %s": "MÅLINGER %d // NESTE %s", + "SCANNING": "SKANNER", + "SCANNING LOCAL CODEX TELEMETRY": "SKANNER LOKAL CODEX-TELEMETRI", + "SESSION // ": "ØKT // ", + "SHOW": "VIS", + "SHOW //": "VIS //", + "SIGNAL FAULT": "SIGNALFEIL", + "STALE": "UTDATERT", + "STALE // ": "UTDATERT // ", + "STALE SIGNAL": "UTDATERT SIGNAL", + "START %s // NOW %s": "START %s // NÅ %s", + "STARTING": "STARTER", + "STARTING MONITOR": "STARTER OVERVÅKING", + "STOP ISSUE": "STOPPFEIL", + "STOP ISSUE // ": "STOPPFEIL // ", + "STOPPED": "STOPPET", + "STOPPED // %d/%d COMPLETE": "STOPPET // %d/%d FULLFØRT", + "STOPPING // %d/%d COMPLETE": "STOPPER // %d/%d FULLFØRT", + "TASK": "OPPGAVE", + "TASK // ": "OPPGAVE // ", + "TASK: %s\n": "OPPGAVE: %s\n", + "THEME // ": "TEMA // ", + "THEME // %s": "TEMA // %s", + "TIME": "TID", + "TIME: %s\n": "TID: %s\n", + "TOKEN BARS": "TOKENSTOLPER", + "TOKENS": "TOKEN", + "TOKENS // ": "TOKEN // ", + "TOKENS // N/A": "TOKEN // N/A", + "TOKENS: %s\n": "TOKEN: %s\n", + "TOKENS: N/A\n": "TOKEN: N/A\n", + "TOTAL %d // INPUT %d // CACHED %d // OUTPUT %d // REASONING %d": "TOTALT %d // INNDATA %d // HURTIGBUFRET %d // UTDATA %d // RESONNERING %d", + "TRANSCRIPT: BENCHMARK-ONLY\n": "UTSKRIFT: KUN TEST\n", + "TURNS": "TURER", + "Token activity history unavailable. Requires a supported Codex CLI and ChatGPT login.": "Tokenhistorikk utilgjengelig. Krever en støttet Codex CLI og ChatGPT-innlogging.", + "UNAVAILABLE // ": "UTILGJENGELIG // ", + "UNKNOWN": "UKJENT", + "UNKNOWN PLAN": "UKJENT PLAN", + "USAGE": "BRUK", + "USED %3d%%": "BRUKT %3d%%", + "USES QUOTA // %d SELECTED MODEL + EFFORT PAIRS": "BRUKER KVOTE // %d VALGTE MODELL- OG NIVÅPAR", + "Use one reset? Refreshes eligible quota and changes the weekly reset schedule. Click CONFIRM; Esc cancels.": "Bruke én tilbakestilling? Fornyer kvalifisert kvote og endrer ukens tilbakestillingstid. Klikk BEKREFT; Esc avbryter.", + "WAITING FOR FIRST RESULT": "VENTER PÅ FØRSTE RESULTAT", + "WAITING FOR FIRST SAMPLE": "VENTER PÅ FØRSTE MÅLING", + "WATCH": "FØLG MED", + "WEEKLY TOKENS": "TOKEN PER UKE", + "WEEKS": "UKER", + "[ (1)2 MONTHS ]": "[ (1)2 MÅNEDER ]", + "[ (6) MONTHS ]": "[ (6) MÅNEDER ]", + "[ (A) RUN ALL // %d ]": "[ (A) KJØR ALLE // %d ]", + "[ (B) RUN SCOPE // %d REMOTE ]": "[ (B) KJØR UTVALG // %d EKSTERN ]", + "[ (B) RUN SCOPE // %d ]": "[ (B) KJØR UTVALG // %d ]", + "[ (C)UMULATIVE ]": "[ (C) AKKUMULERT ]", + "[ (D)AILY ]": "[ (D) DAGLIG ]", + "[ (Q)UIT ]": "[ (Q) AVSLUTT ]", + "[ (R)EFRESH ]": "[ (R) OPPDATER ]", + "[ (S) SCOPE // %d ]": "[ (S) UTVALG // %d ]", + "[ (T)HEME ]": "[ (T) TEMA ]", + "[ (V)IEW ]": "[ (V) VISNING ]", + "[ (W)EEKLY ]": "[ (W) UKENTLIG ]", + "[ (X) STOP ]": "[ (X) STOPP ]", + "[ ALL ]": "[ ALLE ]", + "[ CONFIRM // %d %s ]": "[ BEKREFT // %d %s ]", + "[ CONFIRM // %d REMOTE SESSIONS ]": "[ BEKREFT // %d EKSTERNE ØKTER ]", + "[ CONFIRM RESET ]": "[ BEKREFT TILBAKESTILLING ]", + "[ COST ]": "[ KOSTNAD ]", + "[ DISCOVERING TURNS… ]": "[ FINNER TURER… ]", + "[ FAIL ]": "[ IKKE BESTÅTT ]", + "[ NEWER → ]": "[ NYERE → ]", + "[ PASS ]": "[ BESTÅTT ]", + "[ RESET // %d ]": "[ TILBAKESTILL // %d ]", + "[ RESETTING… ]": "[ TILBAKESTILLER… ]", + "[ RETRY RESET ]": "[ PRØV TILBAKESTILLING IGJEN ]", + "[ SPEED ]": "[ HASTIGHET ]", + "[ ← OLDER ]": "[ ← ELDRE ]", + "[A:CONFIRM %d]": "[A:BEKREFT %d]", + "[ALL:%d]": "[ALLE:%d]", + "[ALL]": "[ALLE]", + "[B:CONFIRM %d]": "[B:BEKREFT %d]", + "[B:SCOPE %d]": "[B:UTVALG %d]", + "[FAIL]": "[IKKE BESTÅTT]", + "[PASS]": "[BESTÅTT]", + "[S:SCOPE %d]": "[S:UTVALG %d]", + "[SCOPE:%d]": "[UTVALG:%d]", + "[STOP]": "[STOPP]", + "╭ BARS ╮": "╭ STOLPER ╮", + "╭ BENCHMARK ╮": "╭ TEST ╮", + "╭ CONSUMPTION PACE ╮": "╭ FORBRUKSTAKT ╮", + "╭ FUEL TANK ╮": "╭ DRIVSTOFFTANK ╮", + "╭ MONITOR ╮": "╭ OVERVÅKING ╮", + "╭ MONITOR ●╮": "╭ OVERVÅKING ●╮", + "╭ PIE ╮": "╭ SEKTOR ╮", + "╭ QUOTA ╮": "╭ KVOTE ╮", + "╭ USAGE ╮": "╭ BRUK ╮", + "╭PIE╮": "╭SEKTOR╮", + "Sun": "søn", + "Mon": "man", + "Tue": "tir", + "Wed": "ons", + "Thu": "tor", + "Fri": "fre", + "Sat": "lør", + "Jan": "jan", + "Feb": "feb", + "Mar": "mar", + "Apr": "apr", + "May": "mai", + "Jun": "jun", + "Jul": "jul", + "Aug": "aug", + "Sep": "sep", + "Oct": "okt", + "Nov": "nov", + "Dec": "des", + " LOOP": " LØKKE", + "[ (C) COPY ]": "[ (C) KOPIER ]", + "[ (X) CLOSE ]": "[ (X) LUKK ]", + "[ (L) CLEAR ALL ]": "[ (L) TØM ALLE ]", + "[ (X) CANCEL ]": "[ (X) AVBRYT ]", + "[ (D) DONE ]": "[ (D) FERDIG ]", + "PRICES RETRIEVED ": "PRISER HENTET " +} diff --git a/internal/i18n/locales/sv.json b/internal/i18n/locales/sv.json new file mode 100644 index 0000000..d1450e3 --- /dev/null +++ b/internal/i18n/locales/sv.json @@ -0,0 +1,363 @@ +{ + "[ H: HIDE DETAIL ]": "[ H: DÖLJ DETALJER ]", + "[ H: SHOW DETAIL ]": "[ H: VISA DETALJER ]", + "This approval action must be handled in Codex.": "Den här åtgärden måste godkännas i Codex.", + "JUSTIFICATION": "MOTIVERING", + "COMMAND": "KOMMANDO", + "WORKING DIRECTORY": "ARBETSKATALOG", + "PERSISTENT PERMISSION RULE": "BESTÄNDIG BEHÖRIGHETSREGEL", + "FOLLOW-UP": "UPPFÖLJNING", + "REPLY": "SVAR", + "[ Click here or press Enter to write ]": "[ Klicka här eller tryck Enter för att skriva ]", + "Enter: send / next answer • Esc: leave editor • ↑/↓: choices": "Enter: skicka / nästa svar • Esc: lämna inmatning • ↑/↓: alternativ", + "Sending…": "Skickar…", + "Text sent ...": "Text skickad ...", + "Send unconfirmed; check Codex before retrying.": "Sändning obekräftad; kontrollera Codex innan du försöker igen.", + "Prompt changed; review the session before replying.": "Frågan ändrades; kontrollera sessionen innan du svarar.", + "Choose an offered answer with ↑/↓.": "Välj ett svar med ↑/↓.", + "OPEN DETAIL": "ÖPPNA DETALJER", + "No supported approval choices were offered.": "Inga godkännandealternativ som stöds erbjöds.", + "[ ALLOW FOR SESSION ]": "[ TILLÅT FÖR SESSIONEN ]", + "[ CONFIRM SESSION GRANT ]": "[ BEKRÄFTA SESSIONSBEHÖRIGHET ]", + "[ ALWAYS ALLOW PREFIX ]": "[ TILLÅT ALLTID PREFIX ]", + "[ CONFIRM PERSISTENT RULE ]": "[ BEKRÄFTA BESTÄNDIG REGEL ]", + "[ REJECT & STOP TURN ]": "[ AVSLÅ & STOPPA TUREN ]", + "OFFERED DECISIONS": "ERBJUDNA BESLUT", + "Questions must be answered in Codex.": "Frågor måste besvaras i Codex.", + "Local observation cannot answer live approvals.": "Lokal avläsning kan inte besvara livegodkännanden.", + "Network approval is not supported here.": "Nätverksgodkännande stöds inte här.", + "Additional permissions require approval in Codex.": "Ytterligare behörigheter måste godkännas i Codex.", + "File-change approval is not supported here.": "Godkännande av filändringar stöds inte här.", + "Unsupported command format.": "Kommandoformatet stöds inte.", + "Command details are missing.": "Kommandouppgifter saknas.", + "Working directory is missing.": "Arbetskatalog saknas.", + "Turn or item identity is missing.": "Turens eller objektets ID saknas.", + "One-time approval and decline are not both offered.": "Engångsgodkännande och avslag erbjuds inte båda.", + "Request details exceed the display limit.": "Begärans uppgifter överskrider visningsgränsen.", + "Request text was altered for safe display.": "Begärans text ändrades för säker visning.", + "No actionable live approval was received.": "Inget hanterbart livegodkännande mottogs.", + "Request resolved, expired, or disconnected.": "Begäran hanterad, utgången eller frånkopplad.", + "Enlarge the terminal to show approval controls.": "Förstora terminalen för att visa godkännandeknapparna.", + "[ APPROVE ONCE ]": "[ GODKÄNN EN GÅNG ]", + "[ CONFIRM APPROVAL ]": "[ BEKRÄFTA GODKÄNNANDE ]", + "[ DECLINE ]": "[ AVSLÅ ]", + "Sending decision…": "Skickar beslut…", + "Decision sent ...": "Beslut skickat ...", + "Decision unconfirmed; check Codex. Do not retry here.": "Beslut obekräftat; kontrollera Codex. Försök inte igen här.", + "LAST REPLY": "SENASTE SVAR", + "QUESTION": "FRÅGA", + "REQUEST": "BEGÄRAN", + "LAST ACTIVITY": "SENASTE AKTIVITET", + "[H:SHOW]": "[H:VISA]", + "[H:HIDE]": "[H:DÖLJ]", + "NO CONTEXT": "INGEN KONTEXT", + "REPLY IN CODEX": "SVARA I CODEX", + "SESSION CONTEXT": "SESSIONSKONTEXT", + "TURN COMPLETE": "TUR KLAR", + "OBSERVED API-EQ // SPEND ~%s // 100%% ~%s // %s · N=%d": "OBSERVERAD API-EQ // FÖRBRUKAT ~%s // 100%% ~%s // %s · N=%d", + "MED": "MEDEL", + "RESULT MATRIX": "RESULTATMATRIS", + "\nBENCHMARK TRANSCRIPT: UNAVAILABLE\n": "\nTESTUTSKRIFT: EJ TILLGÄNGLIGT\n", + " // CREDITS ": " // KREDITER ", + " // CREDITS AVAILABLE": " // KREDITER TILLGÄNGLIGA", + " // CREDITS UNLIMITED": " // OBEGRÄNSADE KREDITER", + " // RESET TOKENS %d": " // ÅTERSTÄLLNINGSTOKEN %d", + " // EFFORT // ": " // NIVÅ // ", + " // LOCAL STALE": " // LOKALT INAKTUELLT", + " // NO INTEGER Δ": " // INGET HELTALS-Δ", + " // PEAK ": " // TOPP ", + " // PUBLISHED CONDITION WITH HIGH": " // PUBLICERAT VILLKOR MED HIGH", + " // R REFRESH": " // R UPPDATERA", + " // R RETRY": " // R FÖRSÖK IGEN", + " // RESET": " // ÅTERSTÄLL", + " // SCALE ": " // SKALA ", + " // SPACE TOGGLE // ESC CANCEL": " // MELLANSLAG VÄXLA // ESC AVBRYT", + " // STALE": " // INAKTUELLT", + " // TIME // ": " // TID // ", + " DAYS": " DAGAR", + " HOURS": " TIMMAR", + " MINUTES": " MINUTER", + " MODELS // ": " MODELLER // ", + " ONLINE // ": " ANSLUTEN // ", + " RANK //": " RANG //", + " REASONING LEVELS // ": " RESONEMANGSNIVÅER // ", + " TOKENS": " TOKEN", + " WEEKS": " VECKOR", + "%s // %d WEEKS": "%s // %d VECKOR", + "%s // BENCHMARK-ONLY // LINES %d-%d/%d": "%s // ENDAST TEST // RADER %d-%d/%d", + "%s QUOTA TELEMETRY CONSOLE · VERSION %s": "%s KONSOL FÖR KVOTTELEMETRI · VERSION %s", + "%s TOKENS // %.0f%% LOCAL": "%s TOKEN // %.0f%% LOKALT", + "(P)AUSE": "(P)AUSA", + "ACCOUNT": "KONTO", + "ACCOUNT // ": "KONTO // ", + "ACCOUNT HISTORY // UTC // %d WEEKS // R REFRESH": "KONTOHISTORIK // UTC // %d VECKOR // R UPPDATERA", + "ACCOUNT HISTORY // UTC // UPDATED ": "KONTOHISTORIK // UTC // UPPDATERAD ", + "ACCOUNT QUOTA Δ": "KONTOKVOT Δ", + "ACCOUNT QUOTA Δ // NO WINDOWS": "KONTOKVOT Δ // INGA PERIODER", + "ACCOUNT QUOTA Δ // UNAVAILABLE": "KONTOKVOT Δ // EJ TILLGÄNGLIGT", + "ACQUIRING SIGNAL": "SÖKER SIGNAL", + "ACTIVE": "AKTIV", + "ACTUAL MODEL: %s\n": "FAKTISK MODELL: %s\n", + "ALGORITHM TRIAL": "ALGORITMTEST", + "API-EQ // BENCHMARK ACTIVE": "API-EQ // TEST AKTIV", + "API-EQ // BENCHMARK SETTLING": "API-EQ // TEST STABILISERAS", + "API-EQ DEFERRED // BENCHMARK ACCOUNTING SETTLING": "API-EQ UPPSKJUTEN // TESTBOKFÖRING STABILISERAS", + "API-EQ DEFERRED // SUBSCRIPTION BENCHMARK ACTIVE": "API-EQ UPPSKJUTEN // PRENUMERATIONSTEST AKTIVT", + "API-EQ LEARNING %d/%dPP": "API-EQ LÄR SIG %d/%dPP", + "API-EQ LEARNING // ": "API-EQ LÄR SIG // ", + "API-EQ LEARNING // %d/%dPP CLEAN MOVEMENT": "API-EQ LÄR SIG // %d/%dPP REN FÖRÄNDRING", + "API-EQ LEARNING // %d/%dPP CLEAN MOVEMENT // RESTARTED: %s": "API-EQ LÄR SIG // %d/%dPP REN FÖRÄNDRING // OMSTARTAD: %s", + "API-EQ N/A // ACCOUNT ATTRIBUTION UNKNOWN": "API-EQ N/A // KONTOKOPPLING OKÄND", + "API-EQ N/A // LIMIT ATTRIBUTION UNKNOWN": "API-EQ N/A // GRÄNSKOPPLING OKÄND", + "API-EQ NOW ~%s // 100%% ~%s · %s%d": "API-EQ NU ~%s // 100%% ~%s · %s%d", + "APPROVAL": "GODKÄNNANDE", + "APPROVAL NEEDED": "GODKÄNNANDE KRÄVS", + "AUTO-SCAN %s": "AUTOSKANNING %s", + "BALANCED +0 POINTS // ON PACE": "BALANS +0 POÄNG // I TAKT", + "BARS": "STAPLAR", + "BENCHMARK": "TEST", + "BENCHMARK // TERMINAL TOO SHORT": "TEST // TERMINALEN ÄR FÖR LÅG", + "BENCHMARK CONTROLS": "TESTKONTROLLER", + "BENCHMARK FAULT": "TESTFEL", + "BENCHMARK SCOPE // %d MODELS // %d EFFORTS // %d PAIRS": "TESTURVAL // %d MODELLER // %d NIVÅER // %d PAR", + "BENCHMARK TRANSCRIPT // UNAVAILABLE": "TESTUTSKRIFT // EJ TILLGÄNGLIGT", + "BRAILLE PIE": "BRAILLECIRKEL", + "CALLS %d // LAST %s": "ANROP %d // SENASTE %s", + "CHECK": "KONTROLLERA", + "CHECK ALL": "MARKERA ALLA", + "CHECK SESSION": "KONTROLLERA SESSIONEN", + "CLEAR": "FRITT", + "CLEAR ALL": "RENSA ALLA", + "CODEXOMETER BENCHMARK RUN DETAIL\n": "CODEXOMETER TESTDETALJER\n", + "COMPLETE // %d/%d PASS": "KLAR // %d/%d GODKÄND", + "COMPLETED RESULTS RETAINED // PRESS B TO RUN AGAIN": "KLARA RESULTAT SPARADE // TRYCK B FÖR NYTT TEST", + "CONSUMPTION PACE": "FÖRBRUKNINGSTAKT", + "CONSUMPTION PACE // TIME - USAGE": "FÖRBRUKNINGSTAKT // TID - ANVÄNDNING", + "CREDITS": "KREDITER", + "CUMULATIVE TOKENS": "ACKUMULERADE TOKEN", + "CYCLE START UNAVAILABLE // ": "PERIODSTART SAKNAS // ", + "DAILY ACTIVITY // %d WEEKS // UTC": "DAGLIG AKTIVITET // %d VECKOR // UTC", + "DAILY TOKENS": "TOKEN PER DAG", + "DAYS": "DAGAR", + "DEFICIT %+d POINTS // OVER PACE": "UNDERSKOTT %+d POÄNG // ÖVER TAKTEN", + "DISCOVERING MODELS": "SÖKER MODELLER", + "DISCOVERING VISIBLE MODELS + EFFORTS": "SÖKER SYNLIGA MODELLER + NIVÅER", + "EFFORT": "NIVÅ", + "EFFORT: %s\n": "NIVÅ: %s\n", + "ELAPSED %s // RATE %s/MIN": "FÖRFLUTET %s // TAKT %s/MIN", + "EQ // BENCH ACTIVE": "EQ // TEST AKTIVT", + "EQ NOW ~%s // FULL ~%s %s%d": "EQ NU ~%s // FULLT ~%s %s%d", + "EST LOCAL-ONLY ": "UPPSKATTNING ENDAST LOKALT ", + "ESTABLISHING A FRESH BASELINE": "SKAPAR NY BASLINJE", + "EXHAUSTED": "SLUT", + "EXTERNAL DIGBENCH // PERSISTED REMOTE SESSION // RANDOM SEED": "EXTERN DIGBENCH // SPARAD FJÄRRSESSION // SLUMPFRÖ", + "Enlarge terminal for daily activity grid (7 weekday rows).": "Förstora terminalen för aktivitetsrutnätet (7 veckodagsrader).", + "FAIL": "UNDERKÄND", + "FAILURE": "FEL", + "FAILURE // ": "FEL // ", + "FETCHING ACCOUNT HISTORY…": "HÄMTAR KONTOHISTORIK…", + "FREE %3d%%": "LEDIGT %3d%%", + "FRESH // GO!": "NYSTART // KÖR!", + "FUEL TANK": "BRÄNSLETANK", + "HANDSHAKE WITH CODEX APP-SERVER IN PROGRESS": "ANSLUTNING TILL CODEX APP-SERVER PÅGÅR", + "HEADROOM %+d POINTS // UNDER PACE": "MARGINAL %+d POÄNG // UNDER TAKTEN", + "HOURS": "TIMMAR", + "IDLE": "VILANDE", + "IN PROGRESS": "PÅGÅR", + "IN PROGRESS (LVL %d)": "PÅGÅR (NIVÅ %d)", + "INCOMPLETE": "OFULLSTÄNDIG", + "INITIALIZING LOCAL SESSION MONITOR": "INITIERAR LOKAL SESSIONSÖVERVAKNING", + "INPUT": "INMATNING", + "INPUT NEEDED": "INMATNING KRÄVS", + "INTERRUPTING CURRENT BENCHMARK TRIAL": "AVBRYTER PÅGÅENDE TEST", + "LAST // ": "SENASTE // ", + "LAST FAIL // ": "SENASTE UNDERKÄND // ", + "LAST N/A // ": "SENASTE N/A // ", + "LAST OUT ": "SENASTE UTDATA ", + "LESS ": "MINDRE ", + "LIFETIME // %s TOKENS PEAK DAY // %s STREAK // %s DAYS": "TOTALT // %s TOKEN TOPPDAG // %s SVIT // %s DAGAR", + "LIMIT NEAR": "NÄRA GRÄNSEN", + "LINEAR PROJECTION // LIMIT IN ~%s // %s EARLY": "LINJÄR PROGNOS // GRÄNS OM ~%s // %s FÖR TIDIGT", + "LINEAR PROJECTION // SAFE THROUGH RESET // ~%d%% LEFT": "LINJÄR PROGNOS // RÄCKER TILL ÅTERSTÄLLNING // ~%d%% KVAR", + "LIVE LOCAL SESSIONS %d // P TO PAUSE": "LOKALA LIVESESSIONER %d // P FÖR PAUS", + "LIVE RUN DETAIL // IN PROGRESS": "DETALJER FÖR PÅGÅENDE TEST // PÅGÅR", + "LOCAL SESSIONS %d // LAST %s AGO": "LOKALA SESSIONER %d // SENAST FÖR %s SEDAN", + "LOCAL SESSIONS %d // P TO RESUME": "LOKALA SESSIONER %d // P FÖR ATT FORTSÄTTA", + "LOCAL TELEMETRY UNAVAILABLE": "LOKAL TELEMETRI SAKNAS", + "LOCAL TOKEN BARS": "LOKALA TOKENSTAPLAR", + "LOW": "LÅG", + "MINUTES": "MINUTER", + "MODEL": "MODELL", + "MODEL // ": "MODELL // ", + "MODEL: %s\n": "MODELL: %s\n", + "MONITOR": "ÖVERVAKNING", + "MONITOR READOUT": "ÖVERVAKNINGSDATA", + "MONITORING ●": "ÖVERVAKAR ●", + "MONTHLY": "MÅNADSVIS", + "MORE // PEAK DAY %s": "MER // TOPPDAG %s", + "NEAR": "NÄRA", + "NO COMPLETE 30 SEC SAMPLE": "INGEN HEL 30-SEKUNDERSMÄTNING", + "NO RESULTS MATCH THE ACTIVE FILTER": "INGA RESULTAT MATCHAR FILTRET", + "NO TOKEN SIGNAL": "INGEN TOKENSIGNAL", + "OBSERVATION DEFERRED": "OBSERVATION UPPSKJUTEN", + "PACE DATA UNAVAILABLE": "TAKTDATA SAKNAS", + "PASS": "GODKÄND", + "PAUSED": "PAUSAD", + "PAUSING": "PAUSAR", + "PIE": "CIRKEL", + "PRESS B OR CLICK TO RUN AGAIN": "TRYCK B ELLER KLICKA FÖR NYTT TEST", + "QUOTA": "KVOT", + "QUOTA CLEAR": "KVOTEN ÄR FRI", + "QUOTA EXHAUSTED": "KVOTEN ÄR SLUT", + "QUOTA RESET": "KVOTÅTERSTÄLLNING", + "QUOTA TELEMETRY // VERSION ": "KVOTTELEMETRI // VERSION ", + "QUOTA UNKNOWN": "OKÄND KVOT", + "QUOTA WATCH": "BEVAKA KVOTEN", + "RANK": "RANG", + "RATE ": "TAKT ", + "RE(S)ET": "ÅTERSTÄLL (S)", + "READING FINAL APPENDED TOKEN TELEMETRY": "LÄSER SISTA TILLAGDA TOKENDATA", + "READY": "REDO", + "REBASING AFTER PAUSED ACTIVITY": "JUSTERAR BASLINJE EFTER PAUS", + "REMOTE SESSIONS": "FJÄRRSESSIONER", + "RESET": "ÅTERSTÄLL", + "RESET CYCLE %3d%%%s // %s": "ÅTERSTÄLLNINGSPERIOD %3d%%%s // %s", + "RESET CYCLE // RESET DATA UNAVAILABLE": "ÅTERSTÄLLNINGSPERIOD // ÅTERSTÄLLNINGSDATA SAKNAS", + "RESET DATA UNAVAILABLE": "ÅTERSTÄLLNINGSDATA SAKNAS", + "RESET FRESH // GO!": "NY KVOT // KÖR!", + "RESET T-%s": "ÅTERSTÄLL T-%s", + "RESET T-%s // %s": "ÅTERSTÄLL T-%s // %s", + "RESETTING": "ÅTERSTÄLLER", + "RESULT": "RESULTAT", + "RESULT // ": "RESULTAT // ", + "RESULT MATRIX // STANDARD API-EQUIVALENT USD": "RESULTATMATRIS // STANDARD API-MOTSVARIGHET USD", + "RESULT: %s\n": "RESULTAT: %s\n", + "RESUME (P)": "FORTSÄTT (P)", + "RESUMING": "FORTSÄTTER", + "ROWS %d-%d/%d": "RADER %d-%d/%d", + "ROWS %d-%d/%d // ↑ ↓ SELECT // ENTER OR CLICK DETAILS": "RADER %d-%d/%d // ↑ ↓ VÄLJ // ENTER ELLER KLICKA FÖR DETALJER", + "RUN DETAIL": "TESTDETALJER", + "RUN SCOPE OR RUN ALL TO BEGIN // THIS CONSUMES CODEX QUOTA": "KÖR URVAL ELLER ALLA FÖR ATT BÖRJA // ANVÄNDER CODEX-KVOT", + "RUNNING %d/%d": "KÖR %d/%d", + "SAMPLES %d // NEXT %s": "MÄTNINGAR %d // NÄSTA %s", + "SCANNING": "SKANNAR", + "SCANNING LOCAL CODEX TELEMETRY": "SKANNAR LOKAL CODEX-TELEMETRI", + "SESSION // ": "SESSION // ", + "SHOW": "VISA", + "SHOW //": "VISA //", + "SIGNAL FAULT": "SIGNALFEL", + "STALE": "INAKTUELLT", + "STALE // ": "INAKTUELLT // ", + "STALE SIGNAL": "INAKTUELL SIGNAL", + "START %s // NOW %s": "START %s // NU %s", + "STARTING": "STARTAR", + "STARTING MONITOR": "STARTAR ÖVERVAKNING", + "STOP ISSUE": "STOPPFEL", + "STOP ISSUE // ": "STOPPFEL // ", + "STOPPED": "STOPPAD", + "STOPPED // %d/%d COMPLETE": "STOPPAD // %d/%d KLAR", + "STOPPING // %d/%d COMPLETE": "STOPPAR // %d/%d KLAR", + "TASK": "UPPGIFT", + "TASK // ": "UPPGIFT // ", + "TASK: %s\n": "UPPGIFT: %s\n", + "THEME // ": "TEMA // ", + "THEME // %s": "TEMA // %s", + "TIME": "TID", + "TIME: %s\n": "TID: %s\n", + "TOKEN BARS": "TOKENSTAPLAR", + "TOKENS": "TOKEN", + "TOKENS // ": "TOKEN // ", + "TOKENS // N/A": "TOKEN // N/A", + "TOKENS: %s\n": "TOKEN: %s\n", + "TOKENS: N/A\n": "TOKEN: N/A\n", + "TOTAL %d // INPUT %d // CACHED %d // OUTPUT %d // REASONING %d": "TOTALT %d // INDATA %d // CACHE %d // UTDATA %d // RESONEMANG %d", + "TRANSCRIPT: BENCHMARK-ONLY\n": "UTSKRIFT: ENDAST TEST\n", + "TURNS": "TURER", + "Token activity history unavailable. Requires a supported Codex CLI and ChatGPT login.": "Tokenhistorik saknas. Kräver en Codex CLI-version som stöds och ChatGPT-inloggning.", + "UNAVAILABLE // ": "EJ TILLGÄNGLIGT // ", + "UNKNOWN": "OKÄNT", + "UNKNOWN PLAN": "OKÄND PLAN", + "USAGE": "ANVÄNDNING", + "USED %3d%%": "ANVÄNT %3d%%", + "USES QUOTA // %d SELECTED MODEL + EFFORT PAIRS": "ANVÄNDER KVOT // %d VALDA MODELL- OCH NIVÅPAR", + "Use one reset? Refreshes eligible quota and changes the weekly reset schedule. Click CONFIRM; Esc cancels.": "Använd en återställning? Förnyar berättigad kvot och ändrar veckans återställningstid. Klicka BEKRÄFTA; Esc avbryter.", + "WAITING FOR FIRST RESULT": "VÄNTAR PÅ FÖRSTA RESULTAT", + "WAITING FOR FIRST SAMPLE": "VÄNTAR PÅ FÖRSTA MÄTNING", + "WATCH": "BEVAKA", + "WEEKLY TOKENS": "TOKEN PER VECKA", + "WEEKS": "VECKOR", + "[ (1)2 MONTHS ]": "[ (1)2 MÅNADER ]", + "[ (6) MONTHS ]": "[ (6) MÅNADER ]", + "[ (A) RUN ALL // %d ]": "[ (A) KÖR ALLA // %d ]", + "[ (B) RUN SCOPE // %d REMOTE ]": "[ (B) KÖR URVAL // %d FJÄRR ]", + "[ (B) RUN SCOPE // %d ]": "[ (B) KÖR URVAL // %d ]", + "[ (C)UMULATIVE ]": "[ (C) ACKUMULERAT ]", + "[ (D)AILY ]": "[ (D) DAGLIGEN ]", + "[ (Q)UIT ]": "[ (Q) AVSLUTA ]", + "[ (R)EFRESH ]": "[ (R) UPPDATERA ]", + "[ (S) SCOPE // %d ]": "[ (S) URVAL // %d ]", + "[ (T)HEME ]": "[ (T) TEMA ]", + "[ (V)IEW ]": "[ (V) VY ]", + "[ (W)EEKLY ]": "[ (W) VECKOVIS ]", + "[ (X) STOP ]": "[ (X) STOPPA ]", + "[ ALL ]": "[ ALLA ]", + "[ CONFIRM // %d %s ]": "[ BEKRÄFTA // %d %s ]", + "[ CONFIRM // %d REMOTE SESSIONS ]": "[ BEKRÄFTA // %d FJÄRRSESSIONER ]", + "[ CONFIRM RESET ]": "[ BEKRÄFTA ÅTERSTÄLLNING ]", + "[ COST ]": "[ KOSTNAD ]", + "[ DISCOVERING TURNS… ]": "[ SÖKER TURER… ]", + "[ FAIL ]": "[ UNDERKÄND ]", + "[ NEWER → ]": "[ NYARE → ]", + "[ PASS ]": "[ GODKÄND ]", + "[ RESET // %d ]": "[ ÅTERSTÄLL // %d ]", + "[ RESETTING… ]": "[ ÅTERSTÄLLER… ]", + "[ RETRY RESET ]": "[ FÖRSÖK ÅTERSTÄLLA IGEN ]", + "[ SPEED ]": "[ HASTIGHET ]", + "[ ← OLDER ]": "[ ← ÄLDRE ]", + "[A:CONFIRM %d]": "[A:BEKRÄFTA %d]", + "[ALL:%d]": "[ALLA:%d]", + "[ALL]": "[ALLA]", + "[B:CONFIRM %d]": "[B:BEKRÄFTA %d]", + "[B:SCOPE %d]": "[B:URVAL %d]", + "[FAIL]": "[UNDERKÄND]", + "[PASS]": "[GODKÄND]", + "[S:SCOPE %d]": "[S:URVAL %d]", + "[SCOPE:%d]": "[URVAL:%d]", + "[STOP]": "[STOPPA]", + "╭ BARS ╮": "╭ STAPLAR ╮", + "╭ BENCHMARK ╮": "╭ TEST ╮", + "╭ CONSUMPTION PACE ╮": "╭ FÖRBRUKNINGSTAKT ╮", + "╭ FUEL TANK ╮": "╭ BRÄNSLETANK ╮", + "╭ MONITOR ╮": "╭ ÖVERVAKNING ╮", + "╭ MONITOR ●╮": "╭ ÖVERVAKNING ●╮", + "╭ PIE ╮": "╭ CIRKEL ╮", + "╭ QUOTA ╮": "╭ KVOT ╮", + "╭ USAGE ╮": "╭ ANVÄNDNING ╮", + "╭PIE╮": "╭CIRKEL╮", + "Sun": "sön", + "Mon": "mån", + "Tue": "tis", + "Wed": "ons", + "Thu": "tor", + "Fri": "fre", + "Sat": "lör", + "Jan": "jan", + "Feb": "feb", + "Mar": "mar", + "Apr": "apr", + "May": "maj", + "Jun": "jun", + "Jul": "jul", + "Aug": "aug", + "Sep": "sep", + "Oct": "okt", + "Nov": "nov", + "Dec": "dec", + " LOOP": " SLINGA", + "[ (C) COPY ]": "[ (C) KOPIERA ]", + "[ (X) CLOSE ]": "[ (X) STÄNG ]", + "[ (L) CLEAR ALL ]": "[ (L) RENSA ALLA ]", + "[ (X) CANCEL ]": "[ (X) AVBRYT ]", + "[ (D) DONE ]": "[ (D) KLART ]", + "PRICES RETRIEVED ": "PRISER HÄMTADE " +} diff --git a/internal/i18n/locales/tr.json b/internal/i18n/locales/tr.json new file mode 100644 index 0000000..c89003f --- /dev/null +++ b/internal/i18n/locales/tr.json @@ -0,0 +1,363 @@ +{ + "[ H: HIDE DETAIL ]": "[ H: AYRINTILARI GİZLE ]", + "[ H: SHOW DETAIL ]": "[ H: AYRINTILARI GÖSTER ]", + "This approval action must be handled in Codex.": "Bu onay işlemi Codex üzerinden yapılmalıdır.", + "JUSTIFICATION": "GEREKÇE", + "COMMAND": "KOMUT", + "WORKING DIRECTORY": "ÇALIŞMA DİZİNİ", + "PERSISTENT PERMISSION RULE": "KALICI İZİN KURALI", + "FOLLOW-UP": "DEVAM İSTEMİ", + "REPLY": "YANIT", + "[ Click here or press Enter to write ]": "[ Yazmak için buraya tıklayın veya Enter'a basın ]", + "Enter: send / next answer • Esc: leave editor • ↑/↓: choices": "Enter: gönder / sonraki yanıt • Esc: düzenleyiciden çık • ↑/↓: seçenekler", + "Sending…": "Gönderiliyor…", + "Text sent ...": "Metin gönderildi ...", + "Send unconfirmed; check Codex before retrying.": "Gönderim doğrulanmadı; yeniden denemeden önce Codex'i kontrol edin.", + "Prompt changed; review the session before replying.": "İstem değişti; yanıtlamadan önce oturumu inceleyin.", + "Choose an offered answer with ↑/↓.": "Sunulan bir yanıtı ↑/↓ ile seçin.", + "OPEN DETAIL": "AYRINTILARI AÇ", + "No supported approval choices were offered.": "Desteklenen bir onay seçeneği sunulmadı.", + "[ ALLOW FOR SESSION ]": "[ OTURUM İÇİN İZİN VER ]", + "[ CONFIRM SESSION GRANT ]": "[ OTURUM İZNİNİ ONAYLA ]", + "[ ALWAYS ALLOW PREFIX ]": "[ ÖNEKE HER ZAMAN İZİN VER ]", + "[ CONFIRM PERSISTENT RULE ]": "[ KALICI KURALI ONAYLA ]", + "[ REJECT & STOP TURN ]": "[ REDDET VE TURU DURDUR ]", + "OFFERED DECISIONS": "SUNULAN KARARLAR", + "Questions must be answered in Codex.": "Sorular Codex'te yanıtlanmalıdır.", + "Local observation cannot answer live approvals.": "Yerel izleme canlı onayları yanıtlayamaz.", + "Network approval is not supported here.": "Ağ onayı burada desteklenmiyor.", + "Additional permissions require approval in Codex.": "Ek izinler Codex'te onaylanmalıdır.", + "File-change approval is not supported here.": "Dosya değişikliği onayı burada desteklenmiyor.", + "Unsupported command format.": "Desteklenmeyen komut biçimi.", + "Command details are missing.": "Komut ayrıntıları eksik.", + "Working directory is missing.": "Çalışma dizini eksik.", + "Turn or item identity is missing.": "Tur veya öğe kimliği eksik.", + "One-time approval and decline are not both offered.": "Tek seferlik onay ve ret birlikte sunulmuyor.", + "Request details exceed the display limit.": "İstek ayrıntıları görüntüleme sınırını aşıyor.", + "Request text was altered for safe display.": "İstek metni güvenli görüntüleme için değiştirildi.", + "No actionable live approval was received.": "İşlenebilir bir canlı onay alınmadı.", + "Request resolved, expired, or disconnected.": "İstek çözüldü, süresi doldu veya bağlantı kesildi.", + "Enlarge the terminal to show approval controls.": "Onay denetimlerini görmek için terminali büyütün.", + "[ APPROVE ONCE ]": "[ BİR KEZ ONAYLA ]", + "[ CONFIRM APPROVAL ]": "[ ONAYI DOĞRULA ]", + "[ DECLINE ]": "[ REDDET ]", + "Sending decision…": "Karar gönderiliyor…", + "Decision sent ...": "Karar gönderildi ...", + "Decision unconfirmed; check Codex. Do not retry here.": "Karar doğrulanmadı; Codex'i kontrol edin. Burada yeniden denemeyin.", + "LAST REPLY": "SON YANIT", + "QUESTION": "SORU", + "REQUEST": "İSTEK", + "LAST ACTIVITY": "SON ETKİNLİK", + "[H:SHOW]": "[H:GÖSTER]", + "[H:HIDE]": "[H:GİZLE]", + "NO CONTEXT": "BAĞLAM YOK", + "REPLY IN CODEX": "CODEX'TE YANITLAYIN", + "SESSION CONTEXT": "OTURUM BAĞLAMI", + "TURN COMPLETE": "TUR TAMAMLANDI", + "OBSERVED API-EQ // SPEND ~%s // 100%% ~%s // %s · N=%d": "GÖZLENEN API-EQ // HARCAMA ~%s // 100%% ~%s // %s · N=%d", + "MED": "ORTA", + "RESULT MATRIX": "SONUÇ MATRİSİ", + "\nBENCHMARK TRANSCRIPT: UNAVAILABLE\n": "\nTEST DÖKÜMÜ: KULLANILAMIYOR\n", + " // CREDITS ": " // KREDİ ", + " // CREDITS AVAILABLE": " // KREDİ MEVCUT", + " // CREDITS UNLIMITED": " // SINIRSIZ KREDİ", + " // RESET TOKENS %d": " // SIFIRLAMA HAKLARI %d", + " // EFFORT // ": " // DÜZEY // ", + " // LOCAL STALE": " // YEREL VERİ ESKİ", + " // NO INTEGER Δ": " // TAMSAYI Δ YOK", + " // PEAK ": " // ZİRVE ", + " // PUBLISHED CONDITION WITH HIGH": " // HIGH İLE YAYIMLANAN KOŞUL", + " // R REFRESH": " // R YENİLE", + " // R RETRY": " // R YENİDEN DENE", + " // RESET": " // SIFIRLA", + " // SCALE ": " // ÖLÇEK ", + " // SPACE TOGGLE // ESC CANCEL": " // BOŞLUK DEĞİŞTİR // ESC İPTAL", + " // STALE": " // ESKİ", + " // TIME // ": " // SÜRE // ", + " DAYS": " GÜN", + " HOURS": " SAAT", + " MINUTES": " DAKİKA", + " MODELS // ": " MODELLER // ", + " ONLINE // ": " ÇEVRİMİÇİ // ", + " RANK //": " SIRA //", + " REASONING LEVELS // ": " AKIL YÜRÜTME DÜZEYLERİ // ", + " TOKENS": " TOKEN", + " WEEKS": " HAFTA", + "%s // %d WEEKS": "%s // %d HAFTA", + "%s // BENCHMARK-ONLY // LINES %d-%d/%d": "%s // YALNIZCA TEST // SATIRLAR %d-%d/%d", + "%s QUOTA TELEMETRY CONSOLE · VERSION %s": "%s KOTA TELEMETRİ KONSOLU · SÜRÜM %s", + "%s TOKENS // %.0f%% LOCAL": "%s TOKEN // %.0f%% YEREL", + "(P)AUSE": "(P) DURAKLAT", + "ACCOUNT": "HESAP", + "ACCOUNT // ": "HESAP // ", + "ACCOUNT HISTORY // UTC // %d WEEKS // R REFRESH": "HESAP GEÇMİŞİ // UTC // %d HAFTA // R YENİLE", + "ACCOUNT HISTORY // UTC // UPDATED ": "HESAP GEÇMİŞİ // UTC // GÜNCELLENDİ ", + "ACCOUNT QUOTA Δ": "HESAP KOTASI Δ", + "ACCOUNT QUOTA Δ // NO WINDOWS": "HESAP KOTASI Δ // DÖNEM YOK", + "ACCOUNT QUOTA Δ // UNAVAILABLE": "HESAP KOTASI Δ // KULLANILAMIYOR", + "ACQUIRING SIGNAL": "SİNYAL ALINIYOR", + "ACTIVE": "ETKİN", + "ACTUAL MODEL: %s\n": "GERÇEK MODEL: %s\n", + "ALGORITHM TRIAL": "ALGORİTMA TESTİ", + "API-EQ // BENCHMARK ACTIVE": "API-EQ // TEST ETKİN", + "API-EQ // BENCHMARK SETTLING": "API-EQ // TEST DENGELENİYOR", + "API-EQ DEFERRED // BENCHMARK ACCOUNTING SETTLING": "API-EQ ERTELENDİ // TEST HESAPLAMASI DENGELENİYOR", + "API-EQ DEFERRED // SUBSCRIPTION BENCHMARK ACTIVE": "API-EQ ERTELENDİ // ABONELİK TESTİ ETKİN", + "API-EQ LEARNING %d/%dPP": "API-EQ ÖĞRENİYOR %d/%dPP", + "API-EQ LEARNING // ": "API-EQ ÖĞRENİYOR // ", + "API-EQ LEARNING // %d/%dPP CLEAN MOVEMENT": "API-EQ ÖĞRENİYOR // %d/%dPP TEMİZ DEĞİŞİM", + "API-EQ LEARNING // %d/%dPP CLEAN MOVEMENT // RESTARTED: %s": "API-EQ ÖĞRENİYOR // %d/%dPP TEMİZ DEĞİŞİM // YENİDEN BAŞLADI: %s", + "API-EQ N/A // ACCOUNT ATTRIBUTION UNKNOWN": "API-EQ N/A // HESAP İLİŞKİSİ BİLİNMİYOR", + "API-EQ N/A // LIMIT ATTRIBUTION UNKNOWN": "API-EQ N/A // SINIR İLİŞKİSİ BİLİNMİYOR", + "API-EQ NOW ~%s // 100%% ~%s · %s%d": "API-EQ ŞİMDİ ~%s // 100%% ~%s · %s%d", + "APPROVAL": "ONAY", + "APPROVAL NEEDED": "ONAY GEREKİYOR", + "AUTO-SCAN %s": "OTOMATİK TARAMA %s", + "BALANCED +0 POINTS // ON PACE": "DENGELİ +0 PUAN // UYGUN HIZDA", + "BARS": "ÇUBUKLAR", + "BENCHMARK": "TEST", + "BENCHMARK // TERMINAL TOO SHORT": "TEST // TERMİNAL ÇOK KISA", + "BENCHMARK CONTROLS": "TEST DENETİMLERİ", + "BENCHMARK FAULT": "TEST HATASI", + "BENCHMARK SCOPE // %d MODELS // %d EFFORTS // %d PAIRS": "TEST KAPSAMI // %d MODEL // %d DÜZEY // %d ÇİFT", + "BENCHMARK TRANSCRIPT // UNAVAILABLE": "TEST DÖKÜMÜ // KULLANILAMIYOR", + "BRAILLE PIE": "BRAILLE PASTA", + "CALLS %d // LAST %s": "ÇAĞRI %d // SON %s", + "CHECK": "KONTROL ET", + "CHECK ALL": "TÜMÜNÜ SEÇ", + "CHECK SESSION": "OTURUMU KONTROL ET", + "CLEAR": "RAHAT", + "CLEAR ALL": "TÜMÜNÜ TEMİZLE", + "CODEXOMETER BENCHMARK RUN DETAIL\n": "CODEXOMETER TEST AYRINTILARI\n", + "COMPLETE // %d/%d PASS": "TAMAMLANDI // %d/%d BAŞARILI", + "COMPLETED RESULTS RETAINED // PRESS B TO RUN AGAIN": "TAMAMLANAN SONUÇLAR SAKLANDI // YENİDEN ÇALIŞTIRMAK İÇİN B", + "CONSUMPTION PACE": "TÜKETİM HIZI", + "CONSUMPTION PACE // TIME - USAGE": "TÜKETİM HIZI // SÜRE - KULLANIM", + "CREDITS": "KREDİ", + "CUMULATIVE TOKENS": "BİRİKİMLİ TOKEN", + "CYCLE START UNAVAILABLE // ": "DÖNEM BAŞLANGICI YOK // ", + "DAILY ACTIVITY // %d WEEKS // UTC": "GÜNLÜK ETKİNLİK // %d HAFTA // UTC", + "DAILY TOKENS": "GÜNLÜK TOKEN", + "DAYS": "GÜN", + "DEFICIT %+d POINTS // OVER PACE": "AÇIK %+d PUAN // HIZIN ÜZERİNDE", + "DISCOVERING MODELS": "MODELLER ARANIYOR", + "DISCOVERING VISIBLE MODELS + EFFORTS": "GÖRÜNÜR MODELLER + DÜZEYLER ARANIYOR", + "EFFORT": "DÜZEY", + "EFFORT: %s\n": "DÜZEY: %s\n", + "ELAPSED %s // RATE %s/MIN": "GEÇEN %s // HIZ %s/MIN", + "EQ // BENCH ACTIVE": "EQ // TEST ETKİN", + "EQ NOW ~%s // FULL ~%s %s%d": "EQ ŞİMDİ ~%s // TAM ~%s %s%d", + "EST LOCAL-ONLY ": "YALNIZCA YEREL TAHMİN ", + "ESTABLISHING A FRESH BASELINE": "YENİ BAŞLANGIÇ DEĞERİ BELİRLENİYOR", + "EXHAUSTED": "TÜKENDİ", + "EXTERNAL DIGBENCH // PERSISTED REMOTE SESSION // RANDOM SEED": "HARİCİ DIGBENCH // KAYITLI UZAK OTURUM // RASTGELE TOHUM", + "Enlarge terminal for daily activity grid (7 weekday rows).": "Günlük etkinlik ızgarası için terminali büyütün (7 gün satırı).", + "FAIL": "BAŞARISIZ", + "FAILURE": "HATA", + "FAILURE // ": "HATA // ", + "FETCHING ACCOUNT HISTORY…": "HESAP GEÇMİŞİ ALINIYOR…", + "FREE %3d%%": "BOŞ %3d%%", + "FRESH // GO!": "YENİLENDİ // BAŞLA!", + "FUEL TANK": "YAKIT DEPOSU", + "HANDSHAKE WITH CODEX APP-SERVER IN PROGRESS": "CODEX APP-SERVER İLE BAĞLANTI KURULUYOR", + "HEADROOM %+d POINTS // UNDER PACE": "PAY %+d PUAN // HIZIN ALTINDA", + "HOURS": "SAAT", + "IDLE": "BOŞTA", + "IN PROGRESS": "SÜRÜYOR", + "IN PROGRESS (LVL %d)": "SÜRÜYOR (DÜZEY %d)", + "INCOMPLETE": "EKSİK", + "INITIALIZING LOCAL SESSION MONITOR": "YEREL OTURUM İZLEYİCİSİ BAŞLATILIYOR", + "INPUT": "GİRDİ", + "INPUT NEEDED": "GİRDİ GEREKİYOR", + "INTERRUPTING CURRENT BENCHMARK TRIAL": "GEÇERLİ TEST KESİLİYOR", + "LAST // ": "SON // ", + "LAST FAIL // ": "SON BAŞARISIZ // ", + "LAST N/A // ": "SON N/A // ", + "LAST OUT ": "SON ÇIKTI ", + "LESS ": "DAHA AZ ", + "LIFETIME // %s TOKENS PEAK DAY // %s STREAK // %s DAYS": "TÜM ZAMANLAR // %s TOKEN ZİRVE GÜN // %s SERİ // %s GÜN", + "LIMIT NEAR": "SINIRA YAKIN", + "LINEAR PROJECTION // LIMIT IN ~%s // %s EARLY": "DOĞRUSAL TAHMİN // SINIRA ~%s // %s ERKEN", + "LINEAR PROJECTION // SAFE THROUGH RESET // ~%d%% LEFT": "DOĞRUSAL TAHMİN // SIFIRLAMAYA KADAR YETERLİ // ~%d%% KALIR", + "LIVE LOCAL SESSIONS %d // P TO PAUSE": "CANLI YEREL OTURUMLAR %d // DURAKLATMAK İÇİN P", + "LIVE RUN DETAIL // IN PROGRESS": "CANLI TEST AYRINTILARI // SÜRÜYOR", + "LOCAL SESSIONS %d // LAST %s AGO": "YEREL OTURUMLAR %d // SON %s ÖNCE", + "LOCAL SESSIONS %d // P TO RESUME": "YEREL OTURUMLAR %d // DEVAM İÇİN P", + "LOCAL TELEMETRY UNAVAILABLE": "YEREL TELEMETRİ YOK", + "LOCAL TOKEN BARS": "YEREL TOKEN ÇUBUKLARI", + "LOW": "DÜŞÜK", + "MINUTES": "DAKİKA", + "MODEL": "MODEL", + "MODEL // ": "MODEL // ", + "MODEL: %s\n": "MODEL: %s\n", + "MONITOR": "İZLEME", + "MONITOR READOUT": "İZLEME VERİLERİ", + "MONITORING ●": "İZLENİYOR ●", + "MONTHLY": "AYLIK", + "MORE // PEAK DAY %s": "DAHA FAZLA // ZİRVE GÜN %s", + "NEAR": "YAKIN", + "NO COMPLETE 30 SEC SAMPLE": "TAM 30 SANİYELİK ÖRNEK YOK", + "NO RESULTS MATCH THE ACTIVE FILTER": "ETKİN FİLTREYE UYAN SONUÇ YOK", + "NO TOKEN SIGNAL": "TOKEN SİNYALİ YOK", + "OBSERVATION DEFERRED": "GÖZLEM ERTELENDİ", + "PACE DATA UNAVAILABLE": "HIZ VERİSİ YOK", + "PASS": "BAŞARILI", + "PAUSED": "DURAKLATILDI", + "PAUSING": "DURAKLATILIYOR", + "PIE": "PASTA", + "PRESS B OR CLICK TO RUN AGAIN": "YENİDEN ÇALIŞTIRMAK İÇİN B VEYA TIKLAYIN", + "QUOTA": "KOTA", + "QUOTA CLEAR": "KOTA RAHAT", + "QUOTA EXHAUSTED": "KOTA TÜKENDİ", + "QUOTA RESET": "KOTA SIFIRLAMA", + "QUOTA TELEMETRY // VERSION ": "KOTA TELEMETRİSİ // SÜRÜM ", + "QUOTA UNKNOWN": "KOTA BİLİNMİYOR", + "QUOTA WATCH": "KOTAYI İZLE", + "RANK": "SIRA", + "RATE ": "HIZ ", + "RE(S)ET": "(S) SIFIRLA", + "READING FINAL APPENDED TOKEN TELEMETRY": "SON EKLENEN TOKEN TELEMETRİSİ OKUNUYOR", + "READY": "HAZIR", + "REBASING AFTER PAUSED ACTIVITY": "DURAKLAMA SONRASI BAŞLANGIÇ DEĞERİ AYARLANIYOR", + "REMOTE SESSIONS": "UZAK OTURUMLAR", + "RESET": "SIFIRLA", + "RESET CYCLE %3d%%%s // %s": "SIFIRLAMA DÖNEMİ %3d%%%s // %s", + "RESET CYCLE // RESET DATA UNAVAILABLE": "SIFIRLAMA DÖNEMİ // SIFIRLAMA VERİSİ YOK", + "RESET DATA UNAVAILABLE": "SIFIRLAMA VERİSİ YOK", + "RESET FRESH // GO!": "KOTA YENİLENDİ // BAŞLA!", + "RESET T-%s": "SIFIRLA T-%s", + "RESET T-%s // %s": "SIFIRLA T-%s // %s", + "RESETTING": "SIFIRLANIYOR", + "RESULT": "SONUÇ", + "RESULT // ": "SONUÇ // ", + "RESULT MATRIX // STANDARD API-EQUIVALENT USD": "SONUÇ MATRİSİ // STANDART API EŞDEĞERİ USD", + "RESULT: %s\n": "SONUÇ: %s\n", + "RESUME (P)": "(P) DEVAM ET", + "RESUMING": "DEVAM EDİLİYOR", + "ROWS %d-%d/%d": "SATIRLAR %d-%d/%d", + "ROWS %d-%d/%d // ↑ ↓ SELECT // ENTER OR CLICK DETAILS": "SATIRLAR %d-%d/%d // ↑ ↓ SEÇ // AYRINTILAR İÇİN ENTER VEYA TIKLAYIN", + "RUN DETAIL": "TEST AYRINTILARI", + "RUN SCOPE OR RUN ALL TO BEGIN // THIS CONSUMES CODEX QUOTA": "BAŞLAMAK İÇİN SEÇİMİ VEYA TÜMÜNÜ ÇALIŞTIRIN // CODEX KOTASI KULLANIR", + "RUNNING %d/%d": "ÇALIŞIYOR %d/%d", + "SAMPLES %d // NEXT %s": "ÖRNEKLER %d // SONRAKİ %s", + "SCANNING": "TARANIYOR", + "SCANNING LOCAL CODEX TELEMETRY": "YEREL CODEX TELEMETRİSİ TARANIYOR", + "SESSION // ": "OTURUM // ", + "SHOW": "GÖSTER", + "SHOW //": "GÖSTER //", + "SIGNAL FAULT": "SİNYAL HATASI", + "STALE": "ESKİ", + "STALE // ": "ESKİ // ", + "STALE SIGNAL": "ESKİ SİNYAL", + "START %s // NOW %s": "BAŞLANGIÇ %s // ŞİMDİ %s", + "STARTING": "BAŞLATILIYOR", + "STARTING MONITOR": "İZLEME BAŞLATILIYOR", + "STOP ISSUE": "DURDURMA SORUNU", + "STOP ISSUE // ": "DURDURMA SORUNU // ", + "STOPPED": "DURDURULDU", + "STOPPED // %d/%d COMPLETE": "DURDURULDU // %d/%d TAMAMLANDI", + "STOPPING // %d/%d COMPLETE": "DURDURULUYOR // %d/%d TAMAMLANDI", + "TASK": "GÖREV", + "TASK // ": "GÖREV // ", + "TASK: %s\n": "GÖREV: %s\n", + "THEME // ": "TEMA // ", + "THEME // %s": "TEMA // %s", + "TIME": "SÜRE", + "TIME: %s\n": "SÜRE: %s\n", + "TOKEN BARS": "TOKEN ÇUBUKLARI", + "TOKENS": "TOKEN", + "TOKENS // ": "TOKEN // ", + "TOKENS // N/A": "TOKEN // N/A", + "TOKENS: %s\n": "TOKEN: %s\n", + "TOKENS: N/A\n": "TOKEN: N/A\n", + "TOTAL %d // INPUT %d // CACHED %d // OUTPUT %d // REASONING %d": "TOPLAM %d // GİRDİ %d // ÖNBELLEK %d // ÇIKTI %d // AKIL YÜRÜTME %d", + "TRANSCRIPT: BENCHMARK-ONLY\n": "DÖKÜM: YALNIZCA TEST\n", + "TURNS": "TURLAR", + "Token activity history unavailable. Requires a supported Codex CLI and ChatGPT login.": "Token etkinlik geçmişi kullanılamıyor. Desteklenen Codex CLI ve ChatGPT girişi gerekir.", + "UNAVAILABLE // ": "KULLANILAMIYOR // ", + "UNKNOWN": "BİLİNMİYOR", + "UNKNOWN PLAN": "BİLİNMEYEN PLAN", + "USAGE": "KULLANIM", + "USED %3d%%": "KULLANILAN %3d%%", + "USES QUOTA // %d SELECTED MODEL + EFFORT PAIRS": "KOTA KULLANIR // %d SEÇİLİ MODEL + DÜZEY ÇİFTİ", + "Use one reset? Refreshes eligible quota and changes the weekly reset schedule. Click CONFIRM; Esc cancels.": "Bir sıfırlama kullanılsın mı? Uygun kotayı yeniler ve haftalık sıfırlama zamanını değiştirir. ONAYLA'ya tıklayın; Esc iptal eder.", + "WAITING FOR FIRST RESULT": "İLK SONUÇ BEKLENİYOR", + "WAITING FOR FIRST SAMPLE": "İLK ÖRNEK BEKLENİYOR", + "WATCH": "İZLE", + "WEEKLY TOKENS": "HAFTALIK TOKEN", + "WEEKS": "HAFTA", + "[ (1)2 MONTHS ]": "[ (1)2 AY ]", + "[ (6) MONTHS ]": "[ (6) AY ]", + "[ (A) RUN ALL // %d ]": "[ (A) TÜMÜNÜ ÇALIŞTIR // %d ]", + "[ (B) RUN SCOPE // %d REMOTE ]": "[ (B) SEÇİMİ ÇALIŞTIR // %d UZAK ]", + "[ (B) RUN SCOPE // %d ]": "[ (B) SEÇİMİ ÇALIŞTIR // %d ]", + "[ (C)UMULATIVE ]": "[ (C) BİRİKİMLİ ]", + "[ (D)AILY ]": "[ (D) GÜNLÜK ]", + "[ (Q)UIT ]": "[ (Q) ÇIK ]", + "[ (R)EFRESH ]": "[ (R) YENİLE ]", + "[ (S) SCOPE // %d ]": "[ (S) KAPSAM // %d ]", + "[ (T)HEME ]": "[ (T) TEMA ]", + "[ (V)IEW ]": "[ (V) GÖRÜNÜM ]", + "[ (W)EEKLY ]": "[ (W) HAFTALIK ]", + "[ (X) STOP ]": "[ (X) DURDUR ]", + "[ ALL ]": "[ TÜMÜ ]", + "[ CONFIRM // %d %s ]": "[ ONAYLA // %d %s ]", + "[ CONFIRM // %d REMOTE SESSIONS ]": "[ ONAYLA // %d UZAK OTURUM ]", + "[ CONFIRM RESET ]": "[ SIFIRLAMAYI ONAYLA ]", + "[ COST ]": "[ MALİYET ]", + "[ DISCOVERING TURNS… ]": "[ TURLAR ARANIYOR… ]", + "[ FAIL ]": "[ BAŞARISIZ ]", + "[ NEWER → ]": "[ DAHA YENİ → ]", + "[ PASS ]": "[ BAŞARILI ]", + "[ RESET // %d ]": "[ SIFIRLA // %d ]", + "[ RESETTING… ]": "[ SIFIRLANIYOR… ]", + "[ RETRY RESET ]": "[ SIFIRLAMAYI YENİDEN DENE ]", + "[ SPEED ]": "[ HIZ ]", + "[ ← OLDER ]": "[ ← DAHA ESKİ ]", + "[A:CONFIRM %d]": "[A:ONAYLA %d]", + "[ALL:%d]": "[TÜMÜ:%d]", + "[ALL]": "[TÜMÜ]", + "[B:CONFIRM %d]": "[B:ONAYLA %d]", + "[B:SCOPE %d]": "[B:KAPSAM %d]", + "[FAIL]": "[BAŞARISIZ]", + "[PASS]": "[BAŞARILI]", + "[S:SCOPE %d]": "[S:KAPSAM %d]", + "[SCOPE:%d]": "[KAPSAM:%d]", + "[STOP]": "[DURDUR]", + "╭ BARS ╮": "╭ ÇUBUKLAR ╮", + "╭ BENCHMARK ╮": "╭ TEST ╮", + "╭ CONSUMPTION PACE ╮": "╭ TÜKETİM HIZI ╮", + "╭ FUEL TANK ╮": "╭ YAKIT DEPOSU ╮", + "╭ MONITOR ╮": "╭ İZLEME ╮", + "╭ MONITOR ●╮": "╭ İZLEME ●╮", + "╭ PIE ╮": "╭ PASTA ╮", + "╭ QUOTA ╮": "╭ KOTA ╮", + "╭ USAGE ╮": "╭ KULLANIM ╮", + "╭PIE╮": "╭PASTA╮", + "Sun": "Paz", + "Mon": "Pzt", + "Tue": "Sal", + "Wed": "Çar", + "Thu": "Per", + "Fri": "Cum", + "Sat": "Cmt", + "Jan": "Oca", + "Feb": "Şub", + "Mar": "Mar", + "Apr": "Nis", + "May": "May", + "Jun": "Haz", + "Jul": "Tem", + "Aug": "Ağu", + "Sep": "Eyl", + "Oct": "Eki", + "Nov": "Kas", + "Dec": "Ara", + " LOOP": " DÖNGÜ", + "[ (C) COPY ]": "[ (C) KOPYALA ]", + "[ (X) CLOSE ]": "[ (X) KAPAT ]", + "[ (L) CLEAR ALL ]": "[ (L) TÜMÜNÜ TEMİZLE ]", + "[ (X) CANCEL ]": "[ (X) İPTAL ]", + "[ (D) DONE ]": "[ (D) BİTTİ ]", + "PRICES RETRIEVED ": "FİYATLAR ALINDI " +} diff --git a/internal/i18n/locales/units.json b/internal/i18n/locales/units.json index 12874b7..aa92fd4 100644 --- a/internal/i18n/locales/units.json +++ b/internal/i18n/locales/units.json @@ -1,4 +1,94 @@ { + "sv": { + "MINUTE": { + "one": "%d MINUT", + "other": "%d MINUTER" + }, + "HOUR": { + "one": "%d TIMME", + "other": "%d TIMMAR" + }, + "DAY": { + "one": "%d DAG", + "other": "%d DAGAR" + }, + "WEEK": { + "one": "%d VECKA", + "other": "%d VECKOR" + } + }, + "nb": { + "MINUTE": { + "one": "%d MINUTT", + "other": "%d MINUTTER" + }, + "HOUR": { + "one": "%d TIME", + "other": "%d TIMER" + }, + "DAY": { + "one": "%d DAG", + "other": "%d DAGER" + }, + "WEEK": { + "one": "%d UKE", + "other": "%d UKER" + } + }, + "tr": { + "MINUTE": { + "one": "%d DAKİKA", + "other": "%d DAKİKA" + }, + "HOUR": { + "one": "%d SAAT", + "other": "%d SAAT" + }, + "DAY": { + "one": "%d GÜN", + "other": "%d GÜN" + }, + "WEEK": { + "one": "%d HAFTA", + "other": "%d HAFTA" + } + }, + "et": { + "MINUTE": { + "one": "%d MINUT", + "other": "%d MINUTIT" + }, + "HOUR": { + "one": "%d TUND", + "other": "%d TUNDI" + }, + "DAY": { + "one": "%d PÄEV", + "other": "%d PÄEVA" + }, + "WEEK": { + "one": "%d NÄDAL", + "other": "%d NÄDALAT" + } + }, + "fi": { + "MINUTE": { + "one": "%d MINUUTTI", + "other": "%d MINUUTTIA" + }, + "HOUR": { + "one": "%d TUNTI", + "other": "%d TUNTIA" + }, + "DAY": { + "one": "%d PÄIVÄ", + "other": "%d PÄIVÄÄ" + }, + "WEEK": { + "one": "%d VIIKKO", + "other": "%d VIIKKOA" + } + }, "en-GB": { "MINUTE": { "one": "%d MINUTE", diff --git a/internal/ui/localisation_test.go b/internal/ui/localisation_test.go index 35e09a1..a3ea345 100644 --- a/internal/ui/localisation_test.go +++ b/internal/ui/localisation_test.go @@ -21,7 +21,7 @@ func TestLocalisedScreens(t *testing.T) { if err != nil { t.Fatal(err) } - for _, code := range []string{"en-GB", "nl", "de", "fr", "it", "es", "ru", "ja", "zh-Hans"} { + for _, code := range []string{"en-GB", "nl", "de", "fr", "it", "es", "ru", "ja", "zh-Hans", "sv", "nb", "tr", "et", "fi"} { t.Run(code, func(t *testing.T) { cmd := exec.Command(exe, "-test.run=^TestLocalisedScreensHelper$") for _, env := range os.Environ() { diff --git a/intro-post.md b/intro-post.md index 4cf33f5..2ea6b36 100644 --- a/intro-post.md +++ b/intro-post.md @@ -17,7 +17,7 @@ It is useful when you want to: - compare available models and reasoning levels on the same checked tasks; or - inspect how a benchmark was solved rather than seeing only a final score. -The interface runs locally in a terminal, with clickable and hover-highlighted controls, keyboard shortcuts, responsive layouts, five colour themes, and nine interface languages. Extra rate-limit windows are accommodated as Codex returns them. Theme, Quota view, benchmark filter, and ranking preferences survive restarts. +The interface runs locally in a terminal, with clickable and hover-highlighted controls, keyboard shortcuts, responsive layouts, five colour themes, and fourteen interface languages. Extra rate-limit windows are accommodated as Codex returns them. Theme, Quota view, benchmark filter, and ranking preferences survive restarts. ## What does it show? @@ -40,11 +40,12 @@ Ordinary Codex CLI sessions work out of the box. For the best Monitor feedback, ## Make it yours -Choose Hacker, Rust, Blue Steel, Ultraviolet, or Nightshade with `t`. UK English remains the default and retains the original presentation. Set `CODEXOMETER_LANG` to opt into Dutch (`nl`), German (`de`), French (`fr`), Italian (`it`), Spanish (`es`), Russian (`ru`), Japanese (`ja`), or Simplified Chinese (`zh-Hans`): +Choose Hacker, Rust, Blue Steel, Ultraviolet, or Nightshade with `t`. UK English remains the default and retains the original presentation. Set `CODEXOMETER_LANG` to opt into Dutch (`nl`), German (`de`), French (`fr`), Italian (`it`), Spanish (`es`), Russian (`ru`), Japanese (`ja`), Simplified Chinese (`zh-Hans`), Swedish (`sv`), Norwegian Bokmål (`nb`, also `no`), Turkish (`tr`), Estonian (`et`), or Finnish (`fi`): ```sh CODEXOMETER_LANG=fr codexometer CODEXOMETER_LANG=ja codexometer --demo +CODEXOMETER_LANG=fi codexometer ``` On PowerShell, use `$env:CODEXOMETER_LANG = 'fr'` before launching. Retain the setting in your shell profile or Windows user environment; restart Codexometer after changing it. Set `en-GB` or remove the variable to restore English. Translations are embedded in the binary; existing hotkeys, model names, and benchmark prompts stay unchanged. These are initial UI translations, and native-language corrections are welcome.