diff --git a/README.md b/README.md index e1821e5..56f1dc9 100644 --- a/README.md +++ b/README.md @@ -1315,6 +1315,22 @@ switch between graph-only, split, expanded, and full detail as described below. in-memory, per-thread context—not a full command history or command output. Missed events (including before attaching) can leave observations incomplete; local-log fallback only reports completion when it was recorded. +- **CURRENT TASK** shows the observed initial prompt while its turn is running; + **LATEST GUIDANCE** keeps subsequent steering separate. Full detail shows the + bounded text, and wide terminal rows show short excerpts. Queued prompts stay + in **FOLLOW-UPS**, not the current task. These fields require live app-server + events: when attaching mid-turn, Codexometer does not invent a missing initial + prompt or use an unsent composer draft. After completion, **TASK** and the latest + guidance remain above the reply until the next turn. Short terminal rows omit + these excerpts when needed to prioritise the reply; full detail retains them. +- **REPLY // STREAMING** grows from live app-server message deltas; commentary + remains distinct from final-answer text. Terminal updates are batched through + the existing active-session refresh (about once a second), not animated typing. + A completed message replaces the partial text authoritatively. Full terminal + detail follows a growing reply when already at the bottom, but preserves your + position after scrolling back. Web detail receives the same bounded live text + through its normal refresh. Local-only mode retains completed-message fallback; + missed events are not reconstructed and the existing context length cap applies. Select a session with `Up`/`Down`, then use `Left` for less detail or `Right` for more. Click the left/right half of that row's combined detail/graph area for the @@ -1487,6 +1503,64 @@ 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. +In terminal **full detail**, or the **selected wide detail row** when its context +and composer fit, a live **WORKING** turn also offers a composer once +its turn ID has been observed. Only the selected row accepts input; switching +sessions retains a separate in-memory ordinary draft for each session; returning +restores it without sending or focusing the editor. Drafts are lost on exit; +secret/question answers and in-flight submissions are not saved as drafts. +The full-detail border and composer heading show the short session ID and name +(directory fallback for unnamed sessions); full IDs remain in secondary metadata. +Switching between wide-row +and full-screen detail for the same session +preserves the draft and releases typing focus; click the composer or press Enter +to continue editing. The animated progress dots sit directly above +the input. While the editor is focused, **Enter steers the current turn**, +**Tab queues the message for the next turn**, and **Esc requests interruption** +without discarding the draft. **Ctrl+C** leaves the editor without interrupting; +Esc outside the editor keeps its normal back-navigation behaviour. Once idle, +Enter sends a normal follow-up. A working-to-idle transition preserves the +draft but never submits it automatically; a key arriving during that transition +is consumed so it cannot accidentally change meaning. Approval and question +controls retain priority over the ordinary composer. + +Queuing uses Codex's experimental `thread/queue/add` API, not Codexometer's timed +scheduler. Codex owns queued-message storage and dispatch, so queued messages +may survive closing Codexometer. A **FOLLOW-UPS** panel above the composer combines +native messages (**QUEUED**) and local **SCHEDULED** triggers +(**AT …** or **AFTER QUOTA REFRESH**). A thin divider and subtle theme tint +separate the panel from the conversation; keyboard selection adds a brighter +row and arrow marker without using approval-warning colours. Scheduled entries aren't numbered as queue +positions: their eligibility, rather than their position in this list, determines +when they run. The panel remains visible during work and ordinary approvals, +above the approval controls when those replace the composer. Quota-threshold +reviews remain isolated. Empty panels disappear unless the queue is unavailable; wide session rows show one +preview at a time, while full detail shows more entries as space permits. + +Use the mouse wheel over the panel, or **Alt+Q** then **↑/↓**, **Page Up/Down**, +to browse. **Enter/E** edits, **X/Delete** requests deletion, and **Esc/Tab** leaves +queue keyboard focus. Each entry also has clickable **EDIT** and **×** buttons; +native deletion requires confirmation. Scheduled **NOW** retains its separate +confirmation. Editing a native message does not pause Codex's queue. Its original +ID and contents are rechecked; an already-started/deleted message is never recreated. +The API has no compare-and-swap edit condition, so avoid editing the same message +simultaneously in multiple clients. Messages with attachments or unsupported +content can be deleted but must be edited in Codex to avoid losing their content. +The native list refreshes every two seconds while selected, with queue-change +notifications invalidating the refresh delay. Failed refreshes mark existing +previews unavailable and disable their actions, and also show an unavailable +indicator when no earlier preview exists. Successful submissions immediately show +an **ACCEPTED // CHECKING QUEUE** receipt while a fresh server list is requested; +this receipt has no edit/delete actions and disappears on reconciliation if the +message has already started. Long context in a wide row retains a compact queue +header; click it or press **Alt+Q** to open full detail. Local triggers still work without +native queue support and remain memory-only: quitting cancels them. + +If the daemon lacks the native API, the action reports an error and +retains the draft—there is no silent local scheduling fallback or automatic +retry. These active-turn controls are currently terminal-only; browser controls +continue to offer idle follow-ups and explicit approvals/questions. + 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 @@ -1501,14 +1575,15 @@ 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. +Drafts are never submitted automatically. Explicitly queued messages are +dispatched by Codex when eligible. 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 +retried: check Codex first. Local-only, disconnected 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. @@ -2460,6 +2535,98 @@ full snapshots on reconnect. Refresh errors and disconnections are labelled; cached observations are not proof that a session is still working. Account history is hidden unless its account matches the current successful quota read. +### Scheduled session follow-ups (experimental) + +In **Sessions → full detail**, use **Ctrl+S** (or click the composer's +schedule heading) to schedule a follow-up for an idle shared app-server session. +The form replaces the conversation area inside the existing detail pane, not +the dashboard. The composer stays anchored below it; on shorter terminals the +form scrolls with the mouse wheel and follows keyboard focus. Esc or Ctrl+S +returns to the conversation, preserving a new follow-up draft. Cancelling an +edit to a saved trigger leaves that trigger and any separate reply draft intact. +Normal Enter-to-send remains immediate. The structured form offers **after quota +refresh**, a delay in hours/minutes, or a calendar date and local time. Use Tab to +move between visible fields (Shift+Tab goes back), arrows to adjust, digits to +enter hours/minutes, and Page Up/Down to change calendar month. Calendar arrows +move by day or week. Enter advances through fields and activates the selected +**Confirm schedule** or **Back** button. Escape returns without saving and leaves +an existing trigger intact. The calendar shows +the local timezone and UTC offset; check the displayed time around daylight-saving +changes. Scheduling and queue controls use the selected interface language; +translated calendars use numeric months/dates and localised weekday labels. +Hover highlights the individual control without changing the selected trigger. +The dashboard tabs, attention pills and footer remain usable while editing; +navigating away retains the unsaved form in memory for that session. Explicit +Back/Esc still cancels an edit. Editing or deleting a native queued message also +stays inside the detail pane; Tab/Shift+Tab selects its controls, Enter confirms +the selected action and Esc cancels. + +Writable web mode offers the same triggers in the full-detail composer, with the +browser's date/time picker and a separate review/confirmation step. Read-only web +mode cannot create or access scheduled prompts. Both composers have a subtle +theme-coloured background. + +- **One pending follow-up per session.** Saving an edit replaces it. The + **TRIGGER SET** label replaces **TURN COMPLETE** in the session status and terminal + pill while a follow-up is queued; it is not repeated beside the token count. + Working and attention-needed statuses remain unchanged. Removing or sending + the trigger restores the underlying status. Full detail retains the saved prompt + and trigger information. + The full-detail border shows the current state; the body uses content labels + such as **FOLLOW-UP** and **LAST REPLY** rather than repeating that state. + Its terminal attention pill comes after TURN COMPLETE, at the lowest priority. + Click the pill or the label in the session's left-hand box to open that session's + detail page with the saved prompt, timing and actions; web provides equivalent + detail links. Use **Ctrl+S** or **Edit** there to amend the schedule. + Dismissing a terminal session row does not hide its pending-trigger pill or + cancel the request; clicking that pill restores the row and opens its detail page. +- Terminal detail shows the saved prompt as a scheduled entry in **FOLLOW-UPS**, + above the ordinary composer, with the local date/time and timezone or quota + recovery condition. **EDIT** opens the existing scheduling form. Actual sending + can be late: the target session must be idle and quota must be available. + The scheduling form previews the exact local date/time for both **IN** and + **AT**, including timezone; delay starts when saved. Invalid messages/times + show an explanation before saving. Existing triggers use **SAVE CHANGES**. + Terminal keyboard hints follow the focused field; the selected trigger stays + underlined when unfocused and filled when focused. + **Ctrl+S** edits; **Ctrl+D** deletes the trigger. **Ctrl+N** requests + **Send now**, followed by **Ctrl+Y** or a confirmation click; Escape cancels that + confirmation. These actions also have clickable buttons. Send now remains + disabled without fresh available quota and an eligible idle session. It shares + the automatic dispatch guard so the same trigger cannot send twice. Editing + invalidates older Send now confirmations. Approval/question controls retain + priority over the pending-trigger controls. +- Writable web detail provides **Edit · Ctrl+S**, **Delete trigger** and separately + confirmed **Send now**. Standard Tab/Shift+Tab and native form keyboard controls + cover the entire web form. Back leaves the existing trigger unchanged. + Browser editing does not pause the saved trigger. If it is changed, deleted or + sent while you edit, the old edit cannot overwrite or recreate it: go back and + reopen the current trigger. Edits are checked against the exact saved job. + Active approval/question and quota-profile review panes omit unrelated saved + follow-up text, without deleting the queued trigger. +- **Memory only:** keep the Codexometer process running. Quitting, restarting or + crashing cancels its queue; it is not a durable background scheduler. Separate + Codexometer processes have separate queues. Closing a browser tab alone does + not stop the web server or its pending requests. +- Sending requires a fresh quota snapshot, the same account and an authoritative + idle follow-up capability from the connected shared app-server. Approval, + question and quota-threshold reviews are not bypassed. Closed/unavailable + sessions wait until eligible or until you cancel them. A due request can send + late after the session becomes idle or the computer resumes. +- **After quota refresh** checks actual quota recovery; reaching a predicted reset + time alone is insufficient. All reported quota windows must have capacity, with + no reported spend-control/limit block. This can conservatively wait for a bucket + unrelated to the selected model. No reset credit is redeemed automatically. + Choosing this trigger when quota is already available sends when next eligible. +- Requests follow the session's model, reasoning and speed settings at dispatch; + no temporary overrides or natural-language time parsing are provided. Sending + starts ordinary Codex work and can consume quota and run tools under that + session's permissions. An uncertain send is retained for inspection and **never + automatically retried**; check Codex before cancelling and scheduling again. + A local preflight deferral known to have sent nothing remains pending instead. + Completed entries are pruned when scheduling new work, so they do not consume + the pending-queue limit. + ### Optional browser session control ```sh diff --git a/internal/codex/daemon_status_unix.go b/internal/codex/daemon_status_unix.go index 7827a6f..1d8eb18 100644 --- a/internal/codex/daemon_status_unix.go +++ b/internal/codex/daemon_status_unix.go @@ -22,8 +22,10 @@ const ( ) type daemonStatusProvider struct { - contexts map[string]*daemonContextState - socketPath string + queueRevision uint64 + queueCapabilities map[string]queueCapability + contexts map[string]*daemonContextState + socketPath string mu sync.Mutex connection *websocket.Conn @@ -394,6 +396,9 @@ func (p *daemonStatusProvider) readLoop(connection *websocket.Conn) { continue } p.mu.Lock() + if envelope.Method == "thread/queue/changed" { + p.queueRevision++ + } if p.contexts == nil { p.contexts = map[string]*daemonContextState{} } diff --git a/internal/codex/live_usage.go b/internal/codex/live_usage.go index 3b67708..b28d397 100644 --- a/internal/codex/live_usage.go +++ b/internal/codex/live_usage.go @@ -1223,8 +1223,15 @@ func (r *LiveUsageReader) sessionSnapshots(now time.Time, liveWriters map[string if exact && preview.pending() { preview = SessionContext{} } - if live, ok := r.daemonContexts[cursor.threadID]; ok && (live.At.After(preview.At) || live.pending() || live.PendingApprovalsLimited) { - preview = live + if live, ok := r.daemonContexts[cursor.threadID]; ok { + if live.At.After(preview.At) || live.pending() || live.PendingApprovalsLimited { + preview = live + } else if preview.Kind == SessionContextReply && live.ThreadID == cursor.threadID && live.TurnID != "" && live.TurnID == cursor.currentTurnID { + // The local task_complete record can be newer than the live + // final message. Keep its reply, but retain observed prompt + // context only for the same positively identified turn. + preview.CurrentTask, preview.LatestGuidance = live.CurrentTask, live.LatestGuidance + } } // A persisted request is not proof that it is still outstanding. Match // it to current attention before displaying it as actionable context. diff --git a/internal/codex/session_context.go b/internal/codex/session_context.go index 1b96b29..5aae254 100644 --- a/internal/codex/session_context.go +++ b/internal/codex/session_context.go @@ -25,7 +25,10 @@ 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 { - Activity SessionActivity + CurrentTask string + LatestGuidance string + Streaming bool + Activity SessionActivity // ApprovalContext is preceding same-thread/turn prose captured when the // request arrived. Display-only: never part of the authorised action. ApprovalContext string @@ -90,7 +93,7 @@ func (c SessionContext) pending() bool { } func preferSessionContext(a, b SessionContext) SessionContext { - if b.Text == "" { + if b.Text == "" && b.CurrentTask == "" && b.LatestGuidance == "" { return a } priority := func(c SessionContext) int { @@ -110,7 +113,7 @@ func preferSessionContext(a, b SessionContext) SessionContext { tie = b.At.Equal(a.At) && (b.ThreadID < a.ThreadID || b.ThreadID == a.ThreadID && b.RequestID < a.RequestID) } - if a.Text == "" || pb > pa || pb == pa && (newer || tie) { + if a.Text == "" && a.CurrentTask == "" && a.LatestGuidance == "" || pb > pa || pb == pa && (newer || tie) { return b } return a diff --git a/internal/codex/session_context_daemon.go b/internal/codex/session_context_daemon.go index 29fa80c..e001fee 100644 --- a/internal/codex/session_context_daemon.go +++ b/internal/codex/session_context_daemon.go @@ -10,14 +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 { - activity sessionActivityState - approvalsLimited bool - promptToken string - promptSpent bool - completed bool - latest SessionContext - requests map[string]SessionContext - commands map[string]contextCommandItem + turnObserved bool + userItems map[string]bool + task, guidance string + ended bool + streamItem, streamTurn, streamText, streamPhase string + activeTurn string + turnToken string + activity sessionActivityState + approvalsLimited bool + promptToken string + promptSpent bool + completed bool + latest SessionContext + requests map[string]SessionContext + commands map[string]contextCommandItem } type contextCommandItem struct{ Command, CWD string } @@ -25,10 +32,12 @@ 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 { + ID string `json:"id"` Status string `json:"status"` } `json:"turn"` TurnID string `json:"turnId"` ItemID string `json:"itemId"` + Delta string `json:"delta"` CWD string `json:"cwd"` Network json.RawMessage `json:"networkApprovalContext"` Permissions json.RawMessage `json:"additionalPermissions"` @@ -43,6 +52,10 @@ func daemonContextEvent(states map[string]*daemonContextState, method string, id Questions []contextQuestion `json:"questions"` IsBlocking *bool `json:"isBlocking"` Item struct { + Content []struct { + Type string `json:"type"` + Text string `json:"text"` + } `json:"content"` ID string `json:"id"` CWD string `json:"cwd"` Type string `json:"type"` @@ -64,16 +77,29 @@ func daemonContextEvent(states map[string]*daemonContextState, method string, id state = &daemonContextState{requests: map[string]SessionContext{}, commands: map[string]contextCommandItem{}} states[p.ThreadID] = state } + if (method == "item/started" || method == "item/completed" || method == "item/agentMessage/delta") && + (state.ended || state.activeTurn != "" && p.TurnID != state.activeTurn) { + return + } c := SessionContext{At: now, ThreadID: p.ThreadID, TurnID: p.TurnID, ItemID: p.ItemID, Source: "LIVE"} commandID := "" if p.Item.ID != "" { commandID = p.TurnID + "/" + p.Item.ID } switch method { - case "turn/started", "thread/closed": + case "turn/started": + states[p.ThreadID] = &daemonContextState{activeTurn: p.Turn.ID, turnObserved: true, requests: map[string]SessionContext{}, commands: map[string]contextCommandItem{}} + return + case "thread/closed": delete(states, p.ThreadID) return case "turn/completed", "turn/interrupted": + // Keep the observed task alongside its reply until the next turn. + state.ended = true + state.userItems = nil + state.turnObserved = false + state.streamItem, state.streamText = "", "" + state.latest.Streaming = false state.promptToken, state.promptSpent = "", false state.completed = method == "turn/completed" && p.Turn.Status == "completed" clear(state.requests) @@ -176,6 +202,13 @@ func daemonContextEvent(states map[string]*daemonContextState, method string, id } } case "item/completed": + if p.Item.Type == "userMessage" { + if state.observeUserMessage(p.Item.ID, p.TurnID, userMessageText(p.Item.Content)) && state.latest.Text == "" { + c.Kind = SessionContextActivity + state.latest = c + } + return + } for key, request := range state.requests { if p.Item.ID != "" && request.ItemID == p.Item.ID && request.TurnID == p.TurnID { delete(state.requests, key) @@ -200,6 +233,12 @@ func daemonContextEvent(states map[string]*daemonContextState, method string, id if p.Item.Type != "agentMessage" { return } + if state.activeTurn != "" && p.TurnID != state.activeTurn { + return + } + if state.streamItem == p.Item.ID { + state.streamItem, state.streamText = "", "" + } c.Kind, c.Text = SessionContextActivity, p.Item.Text if p.Item.Phase == "final_answer" { c.Kind = SessionContextReply @@ -214,6 +253,17 @@ func daemonContextEvent(states map[string]*daemonContextState, method string, id c = state.activity.context(c) } case "item/started": + if p.Item.Type == "userMessage" { + return + } + if p.Item.Type == "agentMessage" { + if state.activeTurn != "" && p.TurnID != state.activeTurn { + return + } + state.streamItem, state.streamTurn, state.streamPhase = p.Item.ID, p.TurnID, p.Item.Phase + state.streamText = boundedStreamText(p.Item.Text) + return + } if p.Item.Type != "commandExecution" { return } @@ -237,6 +287,19 @@ func daemonContextEvent(states map[string]*daemonContextState, method string, id } state.commands[p.TurnID+"/"+p.Item.ID] = contextCommandItem{bounded(p.Item.Command), bounded(p.Item.CWD)} } + case "item/agentMessage/delta": + if p.ItemID == "" || p.ItemID != state.streamItem || p.TurnID != state.streamTurn { + return + } + state.streamText = boundedStreamText(state.streamText + p.Delta) + c.ItemID, c.Streaming = p.ItemID, true + c.Kind, c.Text = SessionContextActivity, SanitizeSessionContext(state.streamText) + if state.streamPhase == "final_answer" { + c.Kind = SessionContextReply + } else { + state.activity.prose, state.activity.proseTurnID = c.Text, p.TurnID + c = state.activity.context(c) + } default: return } @@ -303,10 +366,51 @@ func daemonContextSnapshot(states map[string]*daemonContextState, ids []string, c = preferSessionContext(c, request) } c.PendingApprovals = count + c.CurrentTask, c.LatestGuidance = state.task, state.guidance c.PendingApprovalsLimited = state.approvalsLimited && statuses[id] == sessionRuntimeApproval - if c.Text != "" || c.PendingApprovalsLimited { + if c.Text != "" || c.CurrentTask != "" || c.LatestGuidance != "" || c.PendingApprovalsLimited { out[id] = c } } return out } + +// Retain raw whitespace across deltas, sanitizing the assembled display only. +// The byte cap also bounds malicious escape-only streams before sanitization. +func boundedStreamText(text string) string { + const limit = sessionContextLimit * 8 + if len(text) > limit { + return text[:limit] + } + return text +} + +func userMessageText(content []struct { + Type string `json:"type"` + Text string `json:"text"` +}) string { + var parts []string + for _, item := range content { + if item.Type == "text" { + parts = append(parts, item.Text) + } + } + return SanitizeSessionContext(strings.Join(parts, "\n")) +} + +func (s *daemonContextState) observeUserMessage(id, turn, text string) bool { + if id == "" || s.userItems[id] || s.completed || s.activeTurn != "" && turn != s.activeTurn || len(s.userItems) >= 64 { + return false + } + if s.userItems == nil { + s.userItems = map[string]bool{} + } + first := len(s.userItems) == 0 + s.userItems[id] = true + if first && s.turnObserved { + s.task = text + } else { + s.guidance = text + } + return true +} diff --git a/internal/codex/session_context_test.go b/internal/codex/session_context_test.go index 2b46966..caef0bf 100644 --- a/internal/codex/session_context_test.go +++ b/internal/codex/session_context_test.go @@ -154,7 +154,7 @@ func TestDaemonContextRequestLifecycle(t *testing.T) { t.Fatal(c) } emit("turn/started", "", `{"threadId":"root"}`) - if len(states) != 0 { + if len(daemonContextSnapshot(states, []string{"root"}, nil)) != 0 || states["root"].latest != (SessionContext{}) || len(states["root"].requests) != 0 { t.Fatal("stale context after next turn") } emit("item/tool/requestUserInput", "3", `{"threadId":"root","isBlocking":false,"questions":[{"question":"Optional feedback?"}]}`) diff --git a/internal/codex/session_prompt.go b/internal/codex/session_prompt.go index 863d3ca..392d26e 100644 --- a/internal/codex/session_prompt.go +++ b/internal/codex/session_prompt.go @@ -12,6 +12,32 @@ type SessionPromptOffer struct { Token string ThreadID string Questions []PromptQuestion + TurnID string // Nonempty only for a separately requested active-turn offer. +} + +// Active-turn controls are deliberately separate from idle follow-ups: neither +// scheduled jobs nor browser prompt controls may accidentally steer a turn. +type SessionTurnClient interface { + SessionTurn(string) SessionPromptOffer + SendSessionTurn(context.Context, SessionPromptOffer, string, string) error +} + +func (c Client) SessionTurn(thread string) SessionPromptOffer { + if c.LiveUsage != nil { + if p, ok := c.LiveUsage.statusProvider.(SessionTurnClient); ok { + return p.SessionTurn(thread) + } + } + return SessionPromptOffer{} +} + +func (c Client) SendSessionTurn(ctx context.Context, offer SessionPromptOffer, action, text string) error { + if c.LiveUsage != nil { + if p, ok := c.LiveUsage.statusProvider.(SessionTurnClient); ok { + return p.SendSessionTurn(ctx, offer, action, text) + } + } + return errors.New("live turn controls unavailable; use Codex") } type PromptQuestion struct { diff --git a/internal/codex/session_prompt_unix.go b/internal/codex/session_prompt_unix.go index 3d4b796..1e46659 100644 --- a/internal/codex/session_prompt_unix.go +++ b/internal/codex/session_prompt_unix.go @@ -13,7 +13,11 @@ import ( func (p *daemonStatusProvider) promptLifecycleLocked(method string, raw json.RawMessage) { var event struct { ThreadID string `json:"threadId"` - Status struct { + TurnID string `json:"turnId"` + Turn struct { + ID string `json:"id"` + } `json:"turn"` + Status struct { Type string `json:"type"` ActiveFlags []string `json:"activeFlags"` } `json:"status"` @@ -21,6 +25,20 @@ func (p *daemonStatusProvider) promptLifecycleLocked(method string, raw json.Raw if json.Unmarshal(raw, &event) != nil || event.ThreadID == "" { return } + if method == "turn/started" { + if p.contexts == nil { + p.contexts = map[string]*daemonContextState{} + } + p.contexts[event.ThreadID] = &daemonContextState{activeTurn: event.Turn.ID, turnObserved: true, requests: map[string]SessionContext{}, commands: map[string]contextCommandItem{}} + } + if s := p.contexts[event.ThreadID]; s != nil { + if event.TurnID != "" && s.activeTurn == "" && p.statuses[event.ThreadID] == sessionRuntimeWorking { + s.activeTurn = event.TurnID + } + if method == "turn/completed" || method == "turn/interrupted" { + s.activeTurn, s.turnToken = "", "" + } + } status := sessionRuntimeUnknown switch method { case "turn/started": diff --git a/internal/codex/session_queue.go b/internal/codex/session_queue.go new file mode 100644 index 0000000..a00dda7 --- /dev/null +++ b/internal/codex/session_queue.go @@ -0,0 +1,45 @@ +package codex + +import ( + "context" + "errors" +) + +// Queue entries belong to Codex, not to Codexometer's in-memory scheduler. +type SessionQueuedMessage struct { + ID, Text, Token string + Editable bool +} + +type SessionQueueClient interface { + SessionQueue(context.Context, string) ([]SessionQueuedMessage, error) + ChangeSessionQueue(context.Context, string, SessionQueuedMessage, string, bool) error + SessionQueueRevision() uint64 +} + +func (c Client) queueClient() SessionQueueClient { + if c.LiveUsage != nil { + if p, ok := c.LiveUsage.statusProvider.(SessionQueueClient); ok { + return p + } + } + return nil +} +func (c Client) SessionQueue(ctx context.Context, thread string) ([]SessionQueuedMessage, error) { + if p := c.queueClient(); p != nil { + return p.SessionQueue(ctx, thread) + } + return nil, errors.New("native queue unavailable") +} +func (c Client) ChangeSessionQueue(ctx context.Context, thread string, item SessionQueuedMessage, text string, remove bool) error { + if p := c.queueClient(); p != nil { + return p.ChangeSessionQueue(ctx, thread, item, text, remove) + } + return errors.New("native queue unavailable") +} +func (c Client) SessionQueueRevision() uint64 { + if p := c.queueClient(); p != nil { + return p.SessionQueueRevision() + } + return 0 +} diff --git a/internal/codex/session_queue_unix.go b/internal/codex/session_queue_unix.go new file mode 100644 index 0000000..a1d682b --- /dev/null +++ b/internal/codex/session_queue_unix.go @@ -0,0 +1,157 @@ +//go:build unix + +package codex + +import ( + "context" + "crypto/rand" + "encoding/json" + "errors" + "strings" + + "github.com/gorilla/websocket" +) + +type queueCapability struct { + connection *websocket.Conn + thread string + item SessionQueuedMessage + input string +} + +func (p *daemonStatusProvider) SessionQueueRevision() uint64 { + p.mu.Lock() + defer p.mu.Unlock() + return p.queueRevision +} + +func (p *daemonStatusProvider) SessionQueue(ctx context.Context, thread string) ([]SessionQueuedMessage, error) { + p.mu.Lock() + connection := p.connection + _, subscribed := p.subscribed[thread] + p.mu.Unlock() + if connection == nil || !subscribed { + return nil, errors.New("session unavailable") + } + var out []SessionQueuedMessage + capabilities := map[string]queueCapability{} + cursor := "" + seen := map[string]bool{} + for page := 0; page < 16; page++ { + var result struct { + Data []struct { + ID string `json:"id"` + Input json.RawMessage `json:"input"` + } `json:"data"` + Next string `json:"nextCursor"` + } + params := map[string]any{"threadId": thread, "limit": 100} + if cursor != "" { + params["cursor"] = cursor + } + if err := p.requestOn(ctx, connection, "thread/queue/list", params, &result); err != nil { + return nil, err + } + for _, raw := range result.Data { + if raw.ID == "" || seen[raw.ID] { + return nil, errors.New("inconsistent queue") + } + seen[raw.ID] = true + var inputs []struct { + Type string `json:"type"` + Text string `json:"text"` + Elements []json.RawMessage `json:"text_elements"` + } + if err := json.Unmarshal(raw.Input, &inputs); err != nil { + return nil, err + } + item := SessionQueuedMessage{ID: raw.ID, Token: rand.Text()} + var text []string + for _, input := range inputs { + if input.Type == "text" { + text = append(text, input.Text) + } else { + text = append(text, "["+input.Type+"]") + } + } + item.Text = SanitizeSessionContext(strings.Join(text, "\n")) + item.Editable = len(inputs) == 1 && inputs[0].Type == "text" && len(inputs[0].Elements) == 0 && inputs[0].Text == item.Text && validPromptAnswers(SessionPromptOffer{}, []string{item.Text}) + // Future input metadata must not be silently discarded by our text editor. + var fields []map[string]json.RawMessage + if json.Unmarshal(raw.Input, &fields) == nil && len(fields) == 1 { + for key := range fields[0] { + if key != "type" && key != "text" && key != "text_elements" { + item.Editable = false + } + } + } + capabilities[item.Token] = queueCapability{connection, thread, item, string(raw.Input)} + out = append(out, item) + } + if result.Next == "" { + p.mu.Lock() + defer p.mu.Unlock() + if p.connection != connection { + return nil, errors.New("session connection changed") + } + // Only the selected session is retained; stale edits fail closed. + p.queueCapabilities = capabilities + return out, nil + } + if result.Next == cursor { + return nil, errors.New("queue pagination stalled") + } + cursor = result.Next + } + return nil, errors.New("queue exceeds display limit") +} + +func (p *daemonStatusProvider) ChangeSessionQueue(ctx context.Context, thread string, item SessionQueuedMessage, text string, remove bool) error { + p.mu.Lock() + cap, ok := p.queueCapabilities[item.Token] + if !ok || cap.connection != p.connection || cap.thread != thread || cap.item.ID != item.ID || (!remove && !cap.item.Editable) { + p.mu.Unlock() + return errors.New("queue changed; reload before editing") + } + delete(p.queueCapabilities, item.Token) + p.mu.Unlock() + if !remove && !validPromptAnswers(SessionPromptOffer{}, []string{text}) { + return errors.New("invalid queued message") + } + // Re-read the original item, never add a replacement if it was dispatched. + current, err := p.SessionQueue(ctx, thread) + if err != nil { + return err + } + found := false + for _, candidate := range current { + if candidate.ID == item.ID { + p.mu.Lock() + latest := p.queueCapabilities[candidate.Token] + p.mu.Unlock() + found = latest.connection == cap.connection && latest.input == cap.input + } + } + if !found { + return errors.New("queued message changed or already started") + } + params := map[string]any{"threadId": thread, "queuedSubmissionId": item.ID} + method := "thread/queue/delete" + if !remove { + method = "thread/queue/update" + params["input"] = []any{map[string]any{"type": "text", "text": text}} + } + var result struct { + Deleted bool `json:"deleted"` + Item struct { + ID string `json:"id"` + } `json:"queuedSubmission"` + } + if err := p.requestOn(ctx, cap.connection, method, params, &result); err != nil { + return err + } + if remove && !result.Deleted || !remove && result.Item.ID != item.ID { + return errors.New("queue change not confirmed") + } + return nil +} diff --git a/internal/codex/session_queue_unix_test.go b/internal/codex/session_queue_unix_test.go new file mode 100644 index 0000000..b667ec9 --- /dev/null +++ b/internal/codex/session_queue_unix_test.go @@ -0,0 +1,151 @@ +//go:build unix + +package codex + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + + "github.com/gorilla/websocket" +) + +type queueFixture struct { + mu sync.Mutex + items []map[string]any + writes []daemonEnvelope + fail bool +} + +func nativeQueueProvider(t *testing.T) (*daemonStatusProvider, *queueFixture) { + t.Helper() + f := &queueFixture{items: []map[string]any{{"id": "one", "input": []any{map[string]any{"type": "text", "text": "Original text"}}}, {"id": "two", "input": []any{map[string]any{"type": "image", "url": "image"}}}}} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + conn, err := (&websocket.Upgrader{}).Upgrade(w, r, nil) + if err != nil { + return + } + defer conn.Close() + for { + var e daemonEnvelope + if conn.ReadJSON(&e) != nil { + return + } + var params map[string]any + json.Unmarshal(e.Params, ¶ms) + f.mu.Lock() + var result any + switch e.Method { + case "thread/queue/list": + result = map[string]any{"data": f.items} + case "thread/queue/update": + f.writes = append(f.writes, e) + result = map[string]any{"queuedSubmission": map[string]any{"id": params["queuedSubmissionId"]}} + case "thread/queue/delete": + f.writes = append(f.writes, e) + result = map[string]any{"deleted": true} + } + response := map[string]any{"id": e.ID, "result": result} + if f.fail { + response = map[string]any{"id": e.ID, "error": map[string]any{"code": -32601, "message": "unsupported"}} + } + f.mu.Unlock() + if conn.WriteJSON(response) != nil { + return + } + } + })) + conn, _, err := websocket.DefaultDialer.Dial("ws"+strings.TrimPrefix(server.URL, "http"), nil) + if err != nil { + t.Fatal(err) + } + p := &daemonStatusProvider{connection: conn, pending: map[int64]chan daemonEnvelope{}, subscribed: map[string]struct{}{"root": {}}} + go p.readLoop(conn) + t.Cleanup(func() { p.disconnect(conn); server.Close() }) + return p, f +} + +func TestNativeQueueReadAndMutate(t *testing.T) { + for _, remove := range []bool{false, true} { + p, f := nativeQueueProvider(t) + items, err := p.SessionQueue(context.Background(), "root") + if err != nil { + t.Fatal(err) + } + if len(items) != 2 || items[0].Text != "Original text" || !items[0].Editable || items[1].Editable { + t.Fatal(items) + } + if err = p.ChangeSessionQueue(context.Background(), "root", items[0], "Revised text", remove); err != nil { + t.Fatal(err) + } + f.mu.Lock() + writes := append([]daemonEnvelope(nil), f.writes...) + f.mu.Unlock() + if len(writes) != 1 { + t.Fatal(writes) + } + want := "thread/queue/update" + if remove { + want = "thread/queue/delete" + } + if writes[0].Method != want || !strings.Contains(string(writes[0].Params), `"queuedSubmissionId":"one"`) { + t.Fatal(writes[0]) + } + if err = p.ChangeSessionQueue(context.Background(), "root", items[0], "retry", remove); err == nil { + t.Fatal("reused capability") + } + } +} + +func TestNativeQueueRejectsStaleEditsAndOtherThread(t *testing.T) { + for _, change := range []string{"removed", "changed", "other-thread", "unsupported", "nontext"} { + t.Run(change, func(t *testing.T) { + p, f := nativeQueueProvider(t) + items, err := p.SessionQueue(context.Background(), "root") + if err != nil { + t.Fatal(err) + } + item, thread := items[0], "root" + f.mu.Lock() + switch change { + case "removed": + f.items = nil + case "changed": + f.items[0]["input"] = []any{map[string]any{"type": "text", "text": "Changed elsewhere"}} + case "other-thread": + thread = "another" + case "unsupported": + f.fail = true + case "nontext": + item = items[1] + } + f.mu.Unlock() + if err = p.ChangeSessionQueue(context.Background(), thread, item, "replacement", false); err == nil { + t.Fatal("unsafe edit allowed") + } + f.mu.Lock() + defer f.mu.Unlock() + if len(f.writes) != 0 { + t.Fatal("stale edit mutated queue") + } + }) + } +} + +func TestNativeQueueSanitizesPreview(t *testing.T) { + p, f := nativeQueueProvider(t) + f.mu.Lock() + f.items[0]["input"] = []any{map[string]any{"type": "text", "text": "Hello\x1b[31m red"}} + f.mu.Unlock() + items, err := p.SessionQueue(context.Background(), "root") + if err != nil { + t.Fatal(err) + } + if strings.Contains(items[0].Text, "\x1b") || items[0].Editable { + t.Fatal("terminal control passed through or lossy edit allowed") + } +} diff --git a/internal/codex/session_stream_test.go b/internal/codex/session_stream_test.go new file mode 100644 index 0000000..ecc00f7 --- /dev/null +++ b/internal/codex/session_stream_test.go @@ -0,0 +1,109 @@ +package codex + +import ( + "encoding/json" + "strings" + "testing" + "time" +) + +func TestLiveTaskGuidanceAndStream(t *testing.T) { + states := map[string]*daemonContextState{} + now := time.Now() + event := func(method, raw string) { + now = now.Add(time.Millisecond) + daemonContextEvent(states, method, nil, json.RawMessage(raw), now) + } + snapshot := func() SessionContext { + return daemonContextSnapshot(states, []string{"root"}, map[string]sessionRuntimeStatus{"root": sessionRuntimeWorking})["root"] + } + event("turn/started", `{"threadId":"root","turn":{"id":"turn"}}`) + event("item/completed", `{"threadId":"root","turnId":"turn","item":{"type":"userMessage","id":"u1","content":[{"type":"text","text":"Build it"}]}}`) + if c := snapshot(); c.CurrentTask != "Build it" || c.LatestGuidance != "" { + t.Fatal(c) + } + event("item/completed", `{"threadId":"root","turnId":"turn","item":{"type":"userMessage","id":"u2","content":[{"type":"text","text":"Use Go"}]}}`) + event("item/completed", `{"threadId":"root","turnId":"turn","item":{"type":"userMessage","id":"u1","content":[{"type":"text","text":"Build it"}]}}`) + if c := snapshot(); c.CurrentTask != "Build it" || c.LatestGuidance != "Use Go" { + t.Fatal(c) + } + event("item/started", `{"threadId":"root","turnId":"turn","item":{"type":"agentMessage","id":"a","phase":"final_answer"}}`) + event("item/agentMessage/delta", `{"threadId":"root","turnId":"turn","itemId":"a","delta":"Hello "}`) + event("item/agentMessage/delta", `{"threadId":"root","turnId":"old","itemId":"a","delta":"STALE"}`) + event("item/agentMessage/delta", `{"threadId":"root","turnId":"turn","itemId":"a","delta":"world\n\u001b[31mred\u001b[0m"}`) + if c := snapshot(); c.Text != "Hello world\nred" || !c.Streaming || c.Kind != SessionContextReply { + t.Fatal(c) + } + event("item/completed", `{"threadId":"root","turnId":"turn","item":{"type":"agentMessage","id":"a","phase":"final_answer","text":"Authoritative reply"}}`) + event("item/agentMessage/delta", `{"threadId":"root","turnId":"turn","itemId":"a","delta":"LATE"}`) + if c := snapshot(); c.Text != "Authoritative reply" || c.Streaming { + t.Fatal(c) + } + event("turn/completed", `{"threadId":"root","turn":{"status":"completed"}}`) + if c := snapshot(); c.CurrentTask != "Build it" || c.LatestGuidance != "Use Go" || c.Streaming { + t.Fatal(c) + } + event("turn/started", `{"threadId":"root","turn":{"id":"next"}}`) + if c := snapshot(); c.Text != "" || c.CurrentTask != "" { + t.Fatal(c) + } +} + +func TestStreamBoundsAndMidTurnGuidance(t *testing.T) { + s := daemonContextState{} + s.observeUserMessage("u", "turn", "More guidance") + if s.task != "" || s.guidance != "More guidance" { + t.Fatal("invented initial task") + } + text := boundedStreamText(strings.Repeat("x", sessionContextLimit*10)) + if len(text) != sessionContextLimit*8 || len([]rune(SanitizeSessionContext(text))) != sessionContextLimit { + t.Fatal("unbounded stream") + } +} + +func TestLocalCompletionRetainsSameTurnLiveTask(t *testing.T) { + now := time.Now() + cursor := &rolloutCursor{threadID: "root", currentTurnID: "turn", lastModified: now, + preview: SessionContext{Kind: SessionContextReply, Text: "Final reply", ThreadID: "root", Source: "LOCAL", At: now}} + r := &LiveUsageReader{files: map[string]*rolloutCursor{"root": cursor}, + daemonContexts: map[string]SessionContext{"root": {Kind: SessionContextReply, Text: "Final reply", ThreadID: "root", TurnID: "turn", Source: "LIVE", At: now.Add(-time.Second), CurrentTask: "Build it", LatestGuidance: "Use Go"}}} + sessions, _, _ := r.sessionSnapshots(now, nil, true, map[string]sessionRuntimeStatus{"root": sessionRuntimeIdle}) + if len(sessions) != 1 || sessions[0].Context.CurrentTask != "Build it" || sessions[0].Context.LatestGuidance != "Use Go" { + t.Fatalf("newer local completion discarded the live task: %+v", sessions) + } + for _, turn := range []string{"next-turn", ""} { + cursor.currentTurnID = turn + sessions, _, _ = r.sessionSnapshots(now, nil, true, map[string]sessionRuntimeStatus{"root": sessionRuntimeIdle}) + if sessions[0].Context.CurrentTask != "" || sessions[0].Context.LatestGuidance != "" { + t.Fatal("attached task without matching turn", sessions) + } + } +} + +func TestStreamingCommentaryKeepsCommandAndApprovalPriority(t *testing.T) { + states := map[string]*daemonContextState{} + now := time.Now() + event := func(method, raw string) { + now = now.Add(time.Millisecond) + daemonContextEvent(states, method, json.RawMessage(`1`), json.RawMessage(raw), now) + } + event("turn/started", `{"threadId":"root","turn":{"id":"t"}}`) + event("item/started", `{"threadId":"root","turnId":"t","item":{"type":"commandExecution","id":"cmd","command":"go test","status":"inProgress"}}`) + event("item/started", `{"threadId":"root","turnId":"t","item":{"type":"agentMessage","id":"a","phase":"commentary"}}`) + event("item/agentMessage/delta", `{"threadId":"root","turnId":"t","itemId":"a","delta":"Testing now"}`) + c := daemonContextSnapshot(states, []string{"root"}, nil)["root"] + if c.Kind != SessionContextActivity || c.Activity.Prose != "Testing now" || c.Activity.Command != "go test" { + t.Fatal(c) + } + event("item/commandExecution/requestApproval", `{"threadId":"root","turnId":"t","itemId":"cmd","command":"go test","cwd":"/work","reason":"Permission?"}`) + event("item/agentMessage/delta", `{"threadId":"root","turnId":"t","itemId":"a","delta":" More prose"}`) + c = daemonContextSnapshot(states, []string{"root"}, map[string]sessionRuntimeStatus{"root": sessionRuntimeApproval})["root"] + if c.Kind != SessionContextApproval || c.Streaming || c.ApprovalContext != "Testing now" { + t.Fatal(c) + } + event("turn/interrupted", `{"threadId":"root"}`) + event("item/agentMessage/delta", `{"threadId":"root","turnId":"t","itemId":"a","delta":"LATE"}`) + if states["root"].latest.Streaming || strings.Contains(states["root"].latest.Text, "LATE") { + t.Fatal("stream revived after interruption") + } +} diff --git a/internal/codex/session_turn_unix.go b/internal/codex/session_turn_unix.go new file mode 100644 index 0000000..d8b5b14 --- /dev/null +++ b/internal/codex/session_turn_unix.go @@ -0,0 +1,59 @@ +//go:build unix + +package codex + +import ( + "context" + "crypto/rand" + "errors" +) + +func (p *daemonStatusProvider) SessionTurn(thread string) SessionPromptOffer { + p.mu.Lock() + defer p.mu.Unlock() + s := p.contexts[thread] + _, subscribed := p.subscribed[thread] + if p.connection == nil || !subscribed || s == nil || s.activeTurn == "" || len(s.requests) != 0 || p.statuses[thread] != sessionRuntimeWorking { + return SessionPromptOffer{} + } + if s.turnToken == "" { + s.turnToken = rand.Text() + } + return SessionPromptOffer{Token: s.turnToken, ThreadID: thread, TurnID: s.activeTurn} +} + +func (p *daemonStatusProvider) SendSessionTurn(ctx context.Context, offer SessionPromptOffer, action, text string) error { + if err := ctx.Err(); err != nil { + return err + } + if action != "interrupt" && action != "steer" && action != "queue" { + return errors.New("unknown turn action") + } + if action != "interrupt" && !validPromptAnswers(SessionPromptOffer{}, []string{text}) { + return errors.New("invalid message") + } + p.mu.Lock() + s, connection := p.contexts[offer.ThreadID], p.connection + _, subscribed := p.subscribed[offer.ThreadID] + if connection == nil || !subscribed || s == nil || offer.Token == "" || offer.TurnID == "" || s.turnToken != offer.Token || s.activeTurn != offer.TurnID || len(s.requests) != 0 || p.statuses[offer.ThreadID] != sessionRuntimeWorking { + p.mu.Unlock() + return errors.New("turn changed; review Codex before sending") + } + // Consume before IO; ambiguous mutations are never automatically retried. + s.turnToken = "" + p.mu.Unlock() + params := map[string]any{"threadId": offer.ThreadID} + method := "turn/" + action + switch action { + case "interrupt": + params["turnId"] = offer.TurnID + case "steer": + params["expectedTurnId"] = offer.TurnID + params["input"] = []any{map[string]any{"type": "text", "text": text}} + case "queue": + method = "thread/queue/add" + params["clientUserMessageId"] = rand.Text() + params["input"] = []any{map[string]any{"type": "text", "text": text}} + } + return p.requestOn(ctx, connection, method, params, nil) +} diff --git a/internal/codex/session_turn_unix_test.go b/internal/codex/session_turn_unix_test.go new file mode 100644 index 0000000..04aed13 --- /dev/null +++ b/internal/codex/session_turn_unix_test.go @@ -0,0 +1,83 @@ +//go:build unix + +package codex + +import ( + "context" + "encoding/json" + "testing" +) + +func TestSessionTurnWireAndIsolation(t *testing.T) { + for _, action := range []string{"steer", "queue", "interrupt"} { + t.Run(action, func(t *testing.T) { + p, received := promptProvider(t, "active") + p.mu.Lock() + p.promptLifecycleLocked("turn/started", json.RawMessage(`{"threadId":"root","turn":{"id":"turn-one"}}`)) + p.mu.Unlock() + o := p.SessionTurn("root") + if o.Token == "" || o.TurnID != "turn-one" || p.SessionPrompt("root").Token != "" { + t.Fatal("active offer missing or leaked into idle API") + } + if err := p.SendSessionTurn(context.Background(), o, action, "next task"); err != nil { + t.Fatal(err) + } + e := <-received + var params map[string]json.RawMessage + if err := json.Unmarshal(e.Params, ¶ms); err != nil { + t.Fatal(err) + } + method := "turn/" + action + if action == "queue" { + method = "thread/queue/add" + } + if e.Method != method || string(params["threadId"]) != `"root"` { + t.Fatal(e.Method, string(e.Params)) + } + if action == "steer" && string(params["expectedTurnId"]) != `"turn-one"` { + t.Fatal(string(e.Params)) + } + if action == "interrupt" && string(params["turnId"]) != `"turn-one"` { + t.Fatal(string(e.Params)) + } + if action == "queue" && len(params["clientUserMessageId"]) < 10 { + t.Fatal("missing client identity") + } + if action != "interrupt" && string(params["input"]) != `[{"text":"next task","type":"text"}]` { + t.Fatal(string(e.Params)) + } + if err := p.SendSessionTurn(context.Background(), o, action, "duplicate"); err == nil { + t.Fatal("reused capability") + } + }) + } +} + +func TestSessionTurnRejectsChangedTurnAndApproval(t *testing.T) { + p, received := promptProvider(t, "active") + p.mu.Lock() + p.promptLifecycleLocked("turn/started", json.RawMessage(`{"threadId":"root","turn":{"id":"one"}}`)) + p.mu.Unlock() + o := p.SessionTurn("root") + p.mu.Lock() + p.promptLifecycleLocked("turn/started", json.RawMessage(`{"threadId":"root","turn":{"id":"two"}}`)) + p.mu.Unlock() + if err := p.SendSessionTurn(context.Background(), o, "interrupt", ""); err == nil { + t.Fatal("interrupted replacement turn") + } + o = p.SessionTurn("root") + p.mu.Lock() + p.contexts["root"].requests["approval"] = SessionContext{Kind: SessionContextApproval} + p.mu.Unlock() + if p.SessionTurn("root").Token != "" { + t.Fatal("approval exposed composer") + } + if err := p.SendSessionTurn(context.Background(), o, "queue", "message"); err == nil { + t.Fatal("approval bypassed") + } + select { + case e := <-received: + t.Fatal("unexpected write", e.Method) + default: + } +} diff --git a/internal/i18n/locales/da.json b/internal/i18n/locales/da.json index 9e3db96..ff1b5d4 100644 --- a/internal/i18n/locales/da.json +++ b/internal/i18n/locales/da.json @@ -1,4 +1,9 @@ { + "TRIGGER SET": "UDLØSER INDSTILLET", + "Ctrl+S: EDIT": "Ctrl+S: REDIGER", + "CURRENT TASK": "AKTUEL OPGAVE", + "LATEST GUIDANCE": "SENESTE INSTRUKTION", + "STREAMING": "DIREKTE", "╭ BREAKDOWN ╮": "╭ FORDELING ╮", "╭ WINDOWS ╮": "╭ KVOTEPERIODER ╮", "CONTEXT": "KONTEKST", @@ -413,5 +418,77 @@ "[ (L) CLEAR ALL ]": "[ (L) RYD ALLE ]", "[ (X) CANCEL ]": "[ (X) ANNULLER ]", "[ (D) DONE ]": "[ (D) FÆRDIG ]", - "PRICES RETRIEVED ": "PRISER HENTET " + "PRICES RETRIEVED ": "PRISER HENTET ", + "In memory only • closing Codexometer cancels this trigger": "Kun i hukommelsen • lukning annullerer udløseren", + "Tab/Shift+Tab: fields • Wheel: scroll • Esc: back": "Tab/Shift+Tab: felter • Hjul: rul • Esc: tilbage", + "Will send: ": "Sendes: ", + "Not before this time; when quota + idle allow": "Tidligst da; når kvote og ledig session tillader det", + "Will send: when quota is available": "Sendes når kvote er tilgængelig", + "and this session is idle": "og denne session er inaktiv", + "Enter a message to schedule.": "Skriv en besked.", + "Keep the message within 4096 characters.": "Højst 4096 tegn.", + "Remove terminal control characters.": "Fjern terminalkontroltegn.", + "Choose a future time (delay must exceed 0).": "Vælg et fremtidigt tidspunkt.", + "Choose a time within one year.": "Vælg et tidspunkt inden for et år.", + "Type your message • Enter: next field": "Skriv besked • Enter: næste felt", + "←/→: trigger • Enter: next • q: quit": "←/→: udløser • Enter: næste • q: afslut", + "Arrows: date • PgUp/Dn: month • q: quit": "Pile: dato • PgUp/Dn: måned • q: afslut", + "Digits/arrows: time • Enter: next • q: quit": "Tal/pile: tid • Enter: næste • q: afslut", + "Enter: activate • q: quit": "Enter: aktivér • q: afslut", + "Enlarge the terminal before confirming.": "Gør terminalen større før bekræftelse.", + "Codex may start this message while you review it. Esc: cancel.": "Codex kan starte beskeden under gennemgangen. Esc: annuller.", + "Send this saved prompt now? This starts Codex work.": "Send beskeden nu? Codex begynder at arbejde.", + " // Due in ": " // Om ", + " // Time reached": " // Tidspunkt nået", + " // waiting for fresh quota": " // venter på opdateret kvote", + " // waiting for the original account": " // venter på oprindelig konto", + " // waiting for observed quota windows": " // venter på observerede kvotevinduer", + " // waiting for quota": " // venter på kvote", + " // waiting for session to finish": " // venter på sessionsslut", + " // waiting for an eligible idle session": " // venter på egnet inaktiv session", + " // ready; awaiting dispatch": " // klar; venter på afsendelse", + " // open follow-ups": " // åbn opfølgninger", + " // %d–%d // scroll": " // %d–%d // rul", + " // ↑↓ · Enter: edit · X: delete · Esc": " // ↑↓ · Enter: rediger · X: slet · Esc", + "Status: ": "Status: ", + "Unnamed session": "Unavngivet session", + "SCHEDULE FOLLOW-UP // ": "PLANLÆG // OPFØLGNINGER // ", + "EDIT SCHEDULED FOLLOW-UP // ": "REDIGER // PLANLAGT // OPFØLGNINGER // ", + "AFTER QUOTA REFRESH": "EFTER KVOTENULSTILLING", + "IN…": "OM…", + "AT…": "KL…", + "AT ": "KL ", + "Hours: %d": "Timer: %d", + "Minutes: %02d": "Minutter: %02d", + "Hour: ": "Time: ", + "Minute: ": "Minut: ", + "Su Mo Tu We Th Fr Sa": "Sø Ma Ti On To Fr Lø", + "[ BACK ]": "[ TILBAGE ]", + "[ SAVE CHANGES ]": "[ GEM ÆNDRINGER ]", + "[ CONFIRM SCHEDULE ]": "[ BEKRÆFT PLAN ]", + "[ Ctrl+S: SCHEDULE ]": "[ Ctrl+S: PLANLÆG ]", + "[ Ctrl+S: CLOSE SCHEDULE ]": "[ Ctrl+S: LUK PLANLÆGNING ]", + "QUEUED": "I KØ", + "ACCEPTED // CHECKING QUEUE": "ACCEPTERET // KONTROLLERER KØ", + "SCHEDULED · ": "PLANLAGT · ", + "[NOW]": "[NU]", + "[EDIT]": "[REDIGER]", + "FOLLOW-UPS // %d": "OPFØLGNINGER // %d", + "FOLLOW-UPS // UNAVAILABLE": "OPFØLGNINGER // UTILGÆNGELIG", + "EDIT QUEUED MESSAGE": "REDIGER // I KØ", + "DELETE QUEUED MESSAGE": "SLET // I KØ", + "[ CONFIRM DELETE ]": "[ SLET? ]", + "[ WAIT… ]": "[ VENT… ]", + "[ CANCEL ]": "[ ANNULLER ]", + "After quota recovery": "EFTER KVOTENULSTILLING", + "Not before ": "Sendes: ", + "[ Ctrl+N: SEND NOW ]": "[ Ctrl+N: SEND NU ]", + "[ Ctrl+S: EDIT ]": "[ Ctrl+S: REDIGER ]", + "[ Ctrl+D: DELETE ]": "[ Ctrl+D: SLET ]", + "[ Ctrl+Y: CONFIRM SEND ]": "[ Ctrl+Y: SEND NU? ]", + "[ Esc: CANCEL ]": "[ Esc: ANNULLER ]", + "pending": "afventer", + "sending": "sender", + "uncertain": "uklart", + "sent": "sendt" } diff --git a/internal/i18n/locales/de.json b/internal/i18n/locales/de.json index 4c67295..52c5d39 100644 --- a/internal/i18n/locales/de.json +++ b/internal/i18n/locales/de.json @@ -1,4 +1,9 @@ { + "TRIGGER SET": "AUSLÖSER GESETZT", + "Ctrl+S: EDIT": "Ctrl+S: BEARBEITEN", + "CURRENT TASK": "AKTUELLE AUFGABE", + "LATEST GUIDANCE": "NEUESTE ANWEISUNG", + "STREAMING": "LIVE", "╭ BREAKDOWN ╮": "╭ AUFSCHLÜSSELUNG ╮", "╭ WINDOWS ╮": "╭ KONTINGENTZEITRÄUME ╮", "CONTEXT": "KONTEXT", @@ -413,5 +418,77 @@ "[ (L) CLEAR ALL ]": "[ (L) ALLES LÖSCHEN ]", "[ (X) CANCEL ]": "[ (X) ABBRECHEN ]", "[ (D) DONE ]": "[ (D) FERTIG ]", - "PRICES RETRIEVED ": "PREISE ABGERUFEN " + "PRICES RETRIEVED ": "PREISE ABGERUFEN ", + "In memory only • closing Codexometer cancels this trigger": "Nur im Speicher • Beenden verwirft diesen Auslöser", + "Tab/Shift+Tab: fields • Wheel: scroll • Esc: back": "Tab/Shift+Tab: Felder • Mausrad: scrollen • Esc: zurück", + "Will send: ": "Senden: ", + "Not before this time; when quota + idle allow": "Frühestens dann; bei freiem Kontingent und Leerlauf", + "Will send: when quota is available": "Senden bei verfügbarem Kontingent", + "and this session is idle": "und wenn diese Sitzung inaktiv ist", + "Enter a message to schedule.": "Nachricht eingeben.", + "Keep the message within 4096 characters.": "Höchstens 4096 Zeichen.", + "Remove terminal control characters.": "Terminal-Steuerzeichen entfernen.", + "Choose a future time (delay must exceed 0).": "Einen zukünftigen Zeitpunkt wählen.", + "Choose a time within one year.": "Einen Zeitpunkt innerhalb eines Jahres wählen.", + "Type your message • Enter: next field": "Nachricht eingeben • Enter: nächstes Feld", + "←/→: trigger • Enter: next • q: quit": "←/→: Auslöser • Enter: weiter • q: beenden", + "Arrows: date • PgUp/Dn: month • q: quit": "Pfeile: Datum • PgUp/Dn: Monat • q: beenden", + "Digits/arrows: time • Enter: next • q: quit": "Ziffern/Pfeile: Zeit • Enter: weiter • q: beenden", + "Enter: activate • q: quit": "Enter: aktivieren • q: beenden", + "Enlarge the terminal before confirming.": "Terminal vor dem Bestätigen vergrößern.", + "Codex may start this message while you review it. Esc: cancel.": "Codex könnte diese Nachricht inzwischen starten. Esc: abbrechen.", + "Send this saved prompt now? This starts Codex work.": "Nachricht jetzt senden? Codex beginnt zu arbeiten.", + " // Due in ": " // In ", + " // Time reached": " // Zeitpunkt erreicht", + " // waiting for fresh quota": " // auf aktuelle Kontingentdaten warten", + " // waiting for the original account": " // auf ursprüngliches Konto warten", + " // waiting for observed quota windows": " // auf erfasste Kontingentfenster warten", + " // waiting for quota": " // auf Kontingent warten", + " // waiting for session to finish": " // auf Sitzungsende warten", + " // waiting for an eligible idle session": " // auf geeignete inaktive Sitzung warten", + " // ready; awaiting dispatch": " // bereit; wartet auf Versand", + " // open follow-ups": " // Folgenachrichten öffnen", + " // %d–%d // scroll": " // %d–%d // scrollen", + " // ↑↓ · Enter: edit · X: delete · Esc": " // ↑↓ · Enter: bearbeiten · X: löschen · Esc", + "Status: ": "Status: ", + "Unnamed session": "Unbenannte Sitzung", + "SCHEDULE FOLLOW-UP // ": "PLANEN // FOLGENACHRICHTEN // ", + "EDIT SCHEDULED FOLLOW-UP // ": "BEARBEITEN // GEPLANT // FOLGENACHRICHTEN // ", + "AFTER QUOTA REFRESH": "NACH KONTINGENT-RESET", + "IN…": "IN…", + "AT…": "UM…", + "AT ": "UM ", + "Hours: %d": "Stunden: %d", + "Minutes: %02d": "Minuten: %02d", + "Hour: ": "Stunde: ", + "Minute: ": "Minute: ", + "Su Mo Tu We Th Fr Sa": "So Mo Di Mi Do Fr Sa", + "[ BACK ]": "[ ZURÜCK ]", + "[ SAVE CHANGES ]": "[ ÄNDERUNGEN SPEICHERN ]", + "[ CONFIRM SCHEDULE ]": "[ PLANUNG BESTÄTIGEN ]", + "[ Ctrl+S: SCHEDULE ]": "[ Ctrl+S: PLANEN ]", + "[ Ctrl+S: CLOSE SCHEDULE ]": "[ Ctrl+S: PLANUNG SCHLIESSEN ]", + "QUEUED": "EINGEREIHT", + "ACCEPTED // CHECKING QUEUE": "ANGENOMMEN // WARTESCHLANGE PRÜFEN", + "SCHEDULED · ": "GEPLANT · ", + "[NOW]": "[JETZT]", + "[EDIT]": "[BEARBEITEN]", + "FOLLOW-UPS // %d": "FOLGENACHRICHTEN // %d", + "FOLLOW-UPS // UNAVAILABLE": "FOLGENACHRICHTEN // NICHT VERFÜGBAR", + "EDIT QUEUED MESSAGE": "BEARBEITEN // EINGEREIHT", + "DELETE QUEUED MESSAGE": "LÖSCHEN // EINGEREIHT", + "[ CONFIRM DELETE ]": "[ LÖSCHEN? ]", + "[ WAIT… ]": "[ WARTEN… ]", + "[ CANCEL ]": "[ ABBRECHEN ]", + "After quota recovery": "NACH KONTINGENT-RESET", + "Not before ": "Senden: ", + "[ Ctrl+N: SEND NOW ]": "[ Ctrl+N: JETZT SENDEN ]", + "[ Ctrl+S: EDIT ]": "[ Ctrl+S: BEARBEITEN ]", + "[ Ctrl+D: DELETE ]": "[ Ctrl+D: LÖSCHEN ]", + "[ Ctrl+Y: CONFIRM SEND ]": "[ Ctrl+Y: JETZT SENDEN? ]", + "[ Esc: CANCEL ]": "[ Esc: ABBRECHEN ]", + "pending": "ausstehend", + "sending": "wird gesendet", + "uncertain": "unklar", + "sent": "gesendet" } diff --git a/internal/i18n/locales/en-GB.json b/internal/i18n/locales/en-GB.json index 10d60fc..56411f5 100644 --- a/internal/i18n/locales/en-GB.json +++ b/internal/i18n/locales/en-GB.json @@ -1,4 +1,9 @@ { + "TRIGGER SET": "TRIGGER SET", + "Ctrl+S: EDIT": "Ctrl+S: EDIT", + "CURRENT TASK": "CURRENT TASK", + "LATEST GUIDANCE": "LATEST GUIDANCE", + "STREAMING": "STREAMING", "╭ BREAKDOWN ╮": "╭ BREAKDOWN ╮", "╭ WINDOWS ╮": "╭ WINDOWS ╮", "CONTEXT": "CONTEXT", @@ -413,5 +418,77 @@ "[ (L) CLEAR ALL ]": "[ (L) CLEAR ALL ]", "[ (X) CANCEL ]": "[ (X) CANCEL ]", "[ (D) DONE ]": "[ (D) DONE ]", - "PRICES RETRIEVED ": "PRICES RETRIEVED " + "PRICES RETRIEVED ": "PRICES RETRIEVED ", + "In memory only • closing Codexometer cancels this trigger": "In memory only • closing Codexometer cancels this trigger", + "Tab/Shift+Tab: fields • Wheel: scroll • Esc: back": "Tab/Shift+Tab: fields • Wheel: scroll • Esc: back", + "Will send: ": "Will send: ", + "Not before this time; when quota + idle allow": "Not before this time; when quota + idle allow", + "Will send: when quota is available": "Will send: when quota is available", + "and this session is idle": "and this session is idle", + "Enter a message to schedule.": "Enter a message to schedule.", + "Keep the message within 4096 characters.": "Keep the message within 4096 characters.", + "Remove terminal control characters.": "Remove terminal control characters.", + "Choose a future time (delay must exceed 0).": "Choose a future time (delay must exceed 0).", + "Choose a time within one year.": "Choose a time within one year.", + "Type your message • Enter: next field": "Type your message • Enter: next field", + "←/→: trigger • Enter: next • q: quit": "←/→: trigger • Enter: next • q: quit", + "Arrows: date • PgUp/Dn: month • q: quit": "Arrows: date • PgUp/Dn: month • q: quit", + "Digits/arrows: time • Enter: next • q: quit": "Digits/arrows: time • Enter: next • q: quit", + "Enter: activate • q: quit": "Enter: activate • q: quit", + "Enlarge the terminal before confirming.": "Enlarge the terminal before confirming.", + "Codex may start this message while you review it. Esc: cancel.": "Codex may start this message while you review it. Esc: cancel.", + "Send this saved prompt now? This starts Codex work.": "Send this saved prompt now? This starts Codex work.", + " // Due in ": " // Due in ", + " // Time reached": " // Time reached", + " // waiting for fresh quota": " // waiting for fresh quota", + " // waiting for the original account": " // waiting for the original account", + " // waiting for observed quota windows": " // waiting for observed quota windows", + " // waiting for quota": " // waiting for quota", + " // waiting for session to finish": " // waiting for session to finish", + " // waiting for an eligible idle session": " // waiting for an eligible idle session", + " // ready; awaiting dispatch": " // ready; awaiting dispatch", + " // open follow-ups": " // open follow-ups", + " // %d–%d // scroll": " // %d–%d // scroll", + " // ↑↓ · Enter: edit · X: delete · Esc": " // ↑↓ · Enter: edit · X: delete · Esc", + "Status: ": "Status: ", + "Unnamed session": "Unnamed session", + "SCHEDULE FOLLOW-UP // ": "SCHEDULE FOLLOW-UP // ", + "EDIT SCHEDULED FOLLOW-UP // ": "EDIT SCHEDULED FOLLOW-UP // ", + "AFTER QUOTA REFRESH": "AFTER QUOTA REFRESH", + "IN…": "IN…", + "AT…": "AT…", + "AT ": "AT ", + "Hours: %d": "Hours: %d", + "Minutes: %02d": "Minutes: %02d", + "Hour: ": "Hour: ", + "Minute: ": "Minute: ", + "Su Mo Tu We Th Fr Sa": "Su Mo Tu We Th Fr Sa", + "[ BACK ]": "[ BACK ]", + "[ SAVE CHANGES ]": "[ SAVE CHANGES ]", + "[ CONFIRM SCHEDULE ]": "[ CONFIRM SCHEDULE ]", + "[ Ctrl+S: SCHEDULE ]": "[ Ctrl+S: SCHEDULE ]", + "[ Ctrl+S: CLOSE SCHEDULE ]": "[ Ctrl+S: CLOSE SCHEDULE ]", + "QUEUED": "QUEUED", + "ACCEPTED // CHECKING QUEUE": "ACCEPTED // CHECKING QUEUE", + "SCHEDULED · ": "SCHEDULED · ", + "[NOW]": "[NOW]", + "[EDIT]": "[EDIT]", + "FOLLOW-UPS // %d": "FOLLOW-UPS // %d", + "FOLLOW-UPS // UNAVAILABLE": "FOLLOW-UPS // UNAVAILABLE", + "EDIT QUEUED MESSAGE": "EDIT QUEUED MESSAGE", + "DELETE QUEUED MESSAGE": "DELETE QUEUED MESSAGE", + "[ CONFIRM DELETE ]": "[ CONFIRM DELETE ]", + "[ WAIT… ]": "[ WAIT… ]", + "[ CANCEL ]": "[ CANCEL ]", + "After quota recovery": "After quota recovery", + "Not before ": "Not before ", + "[ Ctrl+N: SEND NOW ]": "[ Ctrl+N: SEND NOW ]", + "[ Ctrl+S: EDIT ]": "[ Ctrl+S: EDIT ]", + "[ Ctrl+D: DELETE ]": "[ Ctrl+D: DELETE ]", + "[ Ctrl+Y: CONFIRM SEND ]": "[ Ctrl+Y: CONFIRM SEND ]", + "[ Esc: CANCEL ]": "[ Esc: CANCEL ]", + "pending": "pending", + "sending": "sending", + "uncertain": "uncertain", + "sent": "sent" } diff --git a/internal/i18n/locales/es.json b/internal/i18n/locales/es.json index 3d94f25..f266934 100644 --- a/internal/i18n/locales/es.json +++ b/internal/i18n/locales/es.json @@ -1,4 +1,9 @@ { + "TRIGGER SET": "ACTIVADOR CONFIGURADO", + "Ctrl+S: EDIT": "Ctrl+S: EDITAR", + "CURRENT TASK": "TAREA ACTUAL", + "LATEST GUIDANCE": "ÚLTIMA INDICACIÓN", + "STREAMING": "EN DIRECTO", "╭ BREAKDOWN ╮": "╭ DESGLOSE ╮", "╭ WINDOWS ╮": "╭ VENTANAS DE CUOTA ╮", "CONTEXT": "CONTEXTO", @@ -413,5 +418,77 @@ "[ (L) CLEAR ALL ]": "[ (L) BORRAR TODO ]", "[ (X) CANCEL ]": "[ (X) CANCELAR ]", "[ (D) DONE ]": "[ (D) LISTO ]", - "PRICES RETRIEVED ": "PRECIOS CONSULTADOS " + "PRICES RETRIEVED ": "PRECIOS CONSULTADOS ", + "In memory only • closing Codexometer cancels this trigger": "Solo en memoria • cerrar cancela este activador", + "Tab/Shift+Tab: fields • Wheel: scroll • Esc: back": "Tab/Shift+Tab: campos • Rueda: desplazar • Esc: volver", + "Will send: ": "Envío: ", + "Not before this time; when quota + idle allow": "No antes; con cuota y sesión inactiva", + "Will send: when quota is available": "Enviar cuando haya cuota disponible", + "and this session is idle": "y esta sesión esté inactiva", + "Enter a message to schedule.": "Escribe un mensaje.", + "Keep the message within 4096 characters.": "Máximo 4096 caracteres.", + "Remove terminal control characters.": "Elimina los caracteres de control del terminal.", + "Choose a future time (delay must exceed 0).": "Elige una hora futura.", + "Choose a time within one year.": "Elige una fecha dentro de un año.", + "Type your message • Enter: next field": "Escribe el mensaje • Enter: siguiente campo", + "←/→: trigger • Enter: next • q: quit": "←/→: activador • Enter: siguiente • q: salir", + "Arrows: date • PgUp/Dn: month • q: quit": "Flechas: fecha • PgUp/Dn: mes • q: salir", + "Digits/arrows: time • Enter: next • q: quit": "Dígitos/flechas: hora • Enter: siguiente • q: salir", + "Enter: activate • q: quit": "Enter: activar • q: salir", + "Enlarge the terminal before confirming.": "Amplía el terminal antes de confirmar.", + "Codex may start this message while you review it. Esc: cancel.": "Codex puede iniciar este mensaje mientras lo revisas. Esc: cancelar.", + "Send this saved prompt now? This starts Codex work.": "¿Enviar ahora este mensaje? Codex empezará a trabajar.", + " // Due in ": " // Dentro de ", + " // Time reached": " // Hora alcanzada", + " // waiting for fresh quota": " // esperando cuota actualizada", + " // waiting for the original account": " // esperando cuenta original", + " // waiting for observed quota windows": " // esperando ventanas de cuota observadas", + " // waiting for quota": " // esperando cuota", + " // waiting for session to finish": " // esperando fin de sesión", + " // waiting for an eligible idle session": " // esperando sesión inactiva apta", + " // ready; awaiting dispatch": " // listo; esperando envío", + " // open follow-ups": " // abrir mensajes siguientes", + " // %d–%d // scroll": " // %d–%d // desplazar", + " // ↑↓ · Enter: edit · X: delete · Esc": " // ↑↓ · Enter: editar · X: eliminar · Esc", + "Status: ": "Estado: ", + "Unnamed session": "Sesión sin nombre", + "SCHEDULE FOLLOW-UP // ": "PROGRAMAR // MENSAJES SIGUIENTES // ", + "EDIT SCHEDULED FOLLOW-UP // ": "EDITAR // PROGRAMADO // MENSAJES SIGUIENTES // ", + "AFTER QUOTA REFRESH": "TRAS RESTABLECER LA CUOTA", + "IN…": "EN…", + "AT…": "A LAS…", + "AT ": "A LAS ", + "Hours: %d": "Horas: %d", + "Minutes: %02d": "Minutos: %02d", + "Hour: ": "Hora: ", + "Minute: ": "Minuto: ", + "Su Mo Tu We Th Fr Sa": "Do Lu Ma Mi Ju Vi Sá", + "[ BACK ]": "[ VOLVER ]", + "[ SAVE CHANGES ]": "[ GUARDAR CAMBIOS ]", + "[ CONFIRM SCHEDULE ]": "[ CONFIRMAR PROGRAMACIÓN ]", + "[ Ctrl+S: SCHEDULE ]": "[ Ctrl+S: PROGRAMAR ]", + "[ Ctrl+S: CLOSE SCHEDULE ]": "[ Ctrl+S: CERRAR PROGRAMACIÓN ]", + "QUEUED": "EN COLA", + "ACCEPTED // CHECKING QUEUE": "ACEPTADO // COMPROBANDO COLA", + "SCHEDULED · ": "PROGRAMADO · ", + "[NOW]": "[AHORA]", + "[EDIT]": "[EDITAR]", + "FOLLOW-UPS // %d": "MENSAJES SIGUIENTES // %d", + "FOLLOW-UPS // UNAVAILABLE": "MENSAJES SIGUIENTES // NO DISPONIBLE", + "EDIT QUEUED MESSAGE": "EDITAR // EN COLA", + "DELETE QUEUED MESSAGE": "ELIMINAR // EN COLA", + "[ CONFIRM DELETE ]": "[ ELIMINAR? ]", + "[ WAIT… ]": "[ ESPERA… ]", + "[ CANCEL ]": "[ CANCELAR ]", + "After quota recovery": "TRAS RESTABLECER LA CUOTA", + "Not before ": "Envío: ", + "[ Ctrl+N: SEND NOW ]": "[ Ctrl+N: ENVIAR AHORA ]", + "[ Ctrl+S: EDIT ]": "[ Ctrl+S: EDITAR ]", + "[ Ctrl+D: DELETE ]": "[ Ctrl+D: ELIMINAR ]", + "[ Ctrl+Y: CONFIRM SEND ]": "[ Ctrl+Y: ENVIAR AHORA? ]", + "[ Esc: CANCEL ]": "[ Esc: CANCELAR ]", + "pending": "pendiente", + "sending": "enviando", + "uncertain": "incierto", + "sent": "enviado" } diff --git a/internal/i18n/locales/et.json b/internal/i18n/locales/et.json index 17b049b..a4d279f 100644 --- a/internal/i18n/locales/et.json +++ b/internal/i18n/locales/et.json @@ -1,4 +1,9 @@ { + "TRIGGER SET": "PÄÄSTIK SEATUD", + "Ctrl+S: EDIT": "Ctrl+S: MUUDA", + "CURRENT TASK": "PRAEGUNE ÜLESANNE", + "LATEST GUIDANCE": "VIIMANE JUHIS", + "STREAMING": "VOOGEDASTUS", "╭ BREAKDOWN ╮": "╭ JAOTUS ╮", "╭ WINDOWS ╮": "╭ KVOODIPERIOODID ╮", "CONTEXT": "KONTEKST", @@ -413,5 +418,77 @@ "[ (L) CLEAR ALL ]": "[ (L) TÜHJENDA KÕIK ]", "[ (X) CANCEL ]": "[ (X) TÜHISTA ]", "[ (D) DONE ]": "[ (D) VALMIS ]", - "PRICES RETRIEVED ": "HINNAD HANGITUD " + "PRICES RETRIEVED ": "HINNAD HANGITUD ", + "In memory only • closing Codexometer cancels this trigger": "Ainult mälus • sulgemine tühistab päästiku", + "Tab/Shift+Tab: fields • Wheel: scroll • Esc: back": "Tab/Shift+Tab: väljad • Ratas: keri • Esc: tagasi", + "Will send: ": "Saatmine: ", + "Not before this time; when quota + idle allow": "Mitte enne; kui kvoot ja jõude seanss lubavad", + "Will send: when quota is available": "Saadetakse, kui kvoot on saadaval", + "and this session is idle": "ja see seanss on jõude", + "Enter a message to schedule.": "Sisesta sõnum.", + "Keep the message within 4096 characters.": "Kuni 4096 märki.", + "Remove terminal control characters.": "Eemalda terminali juhtmärgid.", + "Choose a future time (delay must exceed 0).": "Vali tulevane aeg.", + "Choose a time within one year.": "Vali aeg ühe aasta piires.", + "Type your message • Enter: next field": "Kirjuta sõnum • Enter: järgmine väli", + "←/→: trigger • Enter: next • q: quit": "←/→: päästik • Enter: järgmine • q: välju", + "Arrows: date • PgUp/Dn: month • q: quit": "Nooled: kuupäev • PgUp/Dn: kuu • q: välju", + "Digits/arrows: time • Enter: next • q: quit": "Numbrid/nooled: aeg • Enter: järgmine • q: välju", + "Enter: activate • q: quit": "Enter: käivita • q: välju", + "Enlarge the terminal before confirming.": "Suurenda terminali enne kinnitamist.", + "Codex may start this message while you review it. Esc: cancel.": "Codex võib sõnumi ülevaatuse ajal käivitada. Esc: tühista.", + "Send this saved prompt now? This starts Codex work.": "Saada sõnum kohe? Codex alustab tööd.", + " // Due in ": " // Aega ", + " // Time reached": " // Aeg käes", + " // waiting for fresh quota": " // värske kvoodi ootel", + " // waiting for the original account": " // algse konto ootel", + " // waiting for observed quota windows": " // vaadeldud kvoodiakende ootel", + " // waiting for quota": " // kvoodi ootel", + " // waiting for session to finish": " // seansi lõpu ootel", + " // waiting for an eligible idle session": " // sobiva jõude seansi ootel", + " // ready; awaiting dispatch": " // valmis; saatmise ootel", + " // open follow-ups": " // ava järelsõnumid", + " // %d–%d // scroll": " // %d–%d // keri", + " // ↑↓ · Enter: edit · X: delete · Esc": " // ↑↓ · Enter: muuda · X: kustuta · Esc", + "Status: ": "Olek: ", + "Unnamed session": "Nimetu seanss", + "SCHEDULE FOLLOW-UP // ": "AJASTA // JÄRELSÕNUMID // ", + "EDIT SCHEDULED FOLLOW-UP // ": "MUUDA // AJASTATUD // JÄRELSÕNUMID // ", + "AFTER QUOTA REFRESH": "PÄRAST KVOODI TAASTUMIST", + "IN…": "PÄRAST…", + "AT…": "KELL…", + "AT ": "KELL ", + "Hours: %d": "Tunnid: %d", + "Minutes: %02d": "Minutid: %02d", + "Hour: ": "Tund: ", + "Minute: ": "Minut: ", + "Su Mo Tu We Th Fr Sa": "P E T K N R L", + "[ BACK ]": "[ TAGASI ]", + "[ SAVE CHANGES ]": "[ SALVESTA MUUDATUSED ]", + "[ CONFIRM SCHEDULE ]": "[ KINNITA AJASTUS ]", + "[ Ctrl+S: SCHEDULE ]": "[ Ctrl+S: AJASTA ]", + "[ Ctrl+S: CLOSE SCHEDULE ]": "[ Ctrl+S: SULGE AJASTUS ]", + "QUEUED": "JÄRJEKORRAS", + "ACCEPTED // CHECKING QUEUE": "VASTU VÕETUD // JÄRJEKORRA KONTROLL", + "SCHEDULED · ": "AJASTATUD · ", + "[NOW]": "[KOHE]", + "[EDIT]": "[MUUDA]", + "FOLLOW-UPS // %d": "JÄRELSÕNUMID // %d", + "FOLLOW-UPS // UNAVAILABLE": "JÄRELSÕNUMID // POLE SAADAVAL", + "EDIT QUEUED MESSAGE": "MUUDA // JÄRJEKORRAS", + "DELETE QUEUED MESSAGE": "KUSTUTA // JÄRJEKORRAS", + "[ CONFIRM DELETE ]": "[ KUSTUTA? ]", + "[ WAIT… ]": "[ OOTA… ]", + "[ CANCEL ]": "[ TÜHISTA ]", + "After quota recovery": "PÄRAST KVOODI TAASTUMIST", + "Not before ": "Saatmine: ", + "[ Ctrl+N: SEND NOW ]": "[ Ctrl+N: SAADA KOHE ]", + "[ Ctrl+S: EDIT ]": "[ Ctrl+S: MUUDA ]", + "[ Ctrl+D: DELETE ]": "[ Ctrl+D: KUSTUTA ]", + "[ Ctrl+Y: CONFIRM SEND ]": "[ Ctrl+Y: SAADA KOHE? ]", + "[ Esc: CANCEL ]": "[ Esc: TÜHISTA ]", + "pending": "ootel", + "sending": "saatmisel", + "uncertain": "ebakindel", + "sent": "saadetud" } diff --git a/internal/i18n/locales/fi.json b/internal/i18n/locales/fi.json index 252f57b..91ff96c 100644 --- a/internal/i18n/locales/fi.json +++ b/internal/i18n/locales/fi.json @@ -1,4 +1,9 @@ { + "TRIGGER SET": "EHTO ASETETTU", + "Ctrl+S: EDIT": "Ctrl+S: MUOKKAA", + "CURRENT TASK": "NYKYINEN TEHTÄVÄ", + "LATEST GUIDANCE": "VIIMEISIN OHJE", + "STREAMING": "SUORA", "╭ BREAKDOWN ╮": "╭ ERITTELY ╮", "╭ WINDOWS ╮": "╭ KIINTIÖJAKSOT ╮", "CONTEXT": "TAUSTA", @@ -413,5 +418,77 @@ "[ (L) CLEAR ALL ]": "[ (L) TYHJENNÄ KAIKKI ]", "[ (X) CANCEL ]": "[ (X) PERUUTA ]", "[ (D) DONE ]": "[ (D) VALMIS ]", - "PRICES RETRIEVED ": "HINNAT HAETTU " + "PRICES RETRIEVED ": "HINNAT HAETTU ", + "In memory only • closing Codexometer cancels this trigger": "Vain muistissa • sulkeminen peruuttaa ajastuksen", + "Tab/Shift+Tab: fields • Wheel: scroll • Esc: back": "Tab/Shift+Tab: kentät • Rulla: vieritä • Esc: takaisin", + "Will send: ": "Lähetys: ", + "Not before this time; when quota + idle allow": "Aikaisintaan silloin; kiintiön ja vapaan istunnon salliessa", + "Will send: when quota is available": "Lähetys kiintiön palauduttua", + "and this session is idle": "kun istunto on vapaa", + "Enter a message to schedule.": "Kirjoita viesti.", + "Keep the message within 4096 characters.": "Enintään 4096 merkkiä.", + "Remove terminal control characters.": "Poista päätteen ohjausmerkit.", + "Choose a future time (delay must exceed 0).": "Valitse tuleva aika.", + "Choose a time within one year.": "Valitse aika vuoden sisällä.", + "Type your message • Enter: next field": "Kirjoita viesti • Enter: seuraava kenttä", + "←/→: trigger • Enter: next • q: quit": "←/→: ehto • Enter: seuraava • q: lopeta", + "Arrows: date • PgUp/Dn: month • q: quit": "Nuolet: päivä • PgUp/Dn: kuukausi • q: lopeta", + "Digits/arrows: time • Enter: next • q: quit": "Numerot/nuolet: aika • Enter: seuraava • q: lopeta", + "Enter: activate • q: quit": "Enter: suorita • q: lopeta", + "Enlarge the terminal before confirming.": "Suurenna päätettä ennen vahvistusta.", + "Codex may start this message while you review it. Esc: cancel.": "Codex voi aloittaa viestin tarkistuksen aikana. Esc: peruuta.", + "Send this saved prompt now? This starts Codex work.": "Lähetetäänkö viesti nyt? Codex aloittaa työn.", + " // Due in ": " // Aikaa ", + " // Time reached": " // Aika saavutettu", + " // waiting for fresh quota": " // odottaa tuoretta kiintiötietoa", + " // waiting for the original account": " // odottaa alkuperäistä tiliä", + " // waiting for observed quota windows": " // odottaa havaittuja kiintiöjaksoja", + " // waiting for quota": " // odottaa kiintiötä", + " // waiting for session to finish": " // odottaa istunnon päättymistä", + " // waiting for an eligible idle session": " // odottaa sopivaa vapaata istuntoa", + " // ready; awaiting dispatch": " // valmis; odottaa lähetystä", + " // open follow-ups": " // avaa jatkoviestit", + " // %d–%d // scroll": " // %d–%d // vieritä", + " // ↑↓ · Enter: edit · X: delete · Esc": " // ↑↓ · Enter: muokkaa · X: poista · Esc", + "Status: ": "Tila: ", + "Unnamed session": "Nimetön istunto", + "SCHEDULE FOLLOW-UP // ": "AJASTA // JATKOVIESTIT // ", + "EDIT SCHEDULED FOLLOW-UP // ": "MUOKKAA // AJASTETTU // JATKOVIESTIT // ", + "AFTER QUOTA REFRESH": "KIINTIÖN PALAUDUTTUA", + "IN…": "KULUTTUA…", + "AT…": "KLO…", + "AT ": "KLO ", + "Hours: %d": "Tunnit: %d", + "Minutes: %02d": "Minuutit: %02d", + "Hour: ": "Tunti: ", + "Minute: ": "Minuutti: ", + "Su Mo Tu We Th Fr Sa": "Su Ma Ti Ke To Pe La", + "[ BACK ]": "[ TAKAISIN ]", + "[ SAVE CHANGES ]": "[ TALLENNA MUUTOKSET ]", + "[ CONFIRM SCHEDULE ]": "[ VAHVISTA AJASTUS ]", + "[ Ctrl+S: SCHEDULE ]": "[ Ctrl+S: AJASTA ]", + "[ Ctrl+S: CLOSE SCHEDULE ]": "[ Ctrl+S: SULJE AJASTUS ]", + "QUEUED": "JONOSSA", + "ACCEPTED // CHECKING QUEUE": "HYVÄKSYTTY // TARKISTETAAN JONOA", + "SCHEDULED · ": "AJASTETTU · ", + "[NOW]": "[NYT]", + "[EDIT]": "[MUOKKAA]", + "FOLLOW-UPS // %d": "JATKOVIESTIT // %d", + "FOLLOW-UPS // UNAVAILABLE": "JATKOVIESTIT // EI SAATAVILLA", + "EDIT QUEUED MESSAGE": "MUOKKAA // JONOSSA", + "DELETE QUEUED MESSAGE": "POISTA // JONOSSA", + "[ CONFIRM DELETE ]": "[ POISTA? ]", + "[ WAIT… ]": "[ ODOTA… ]", + "[ CANCEL ]": "[ PERUUTA ]", + "After quota recovery": "KIINTIÖN PALAUDUTTUA", + "Not before ": "Lähetys: ", + "[ Ctrl+N: SEND NOW ]": "[ Ctrl+N: LÄHETÄ NYT ]", + "[ Ctrl+S: EDIT ]": "[ Ctrl+S: MUOKKAA ]", + "[ Ctrl+D: DELETE ]": "[ Ctrl+D: POISTA ]", + "[ Ctrl+Y: CONFIRM SEND ]": "[ Ctrl+Y: LÄHETÄ NYT? ]", + "[ Esc: CANCEL ]": "[ Esc: PERUUTA ]", + "pending": "odottaa", + "sending": "lähetetään", + "uncertain": "epäselvä", + "sent": "lähetetty" } diff --git a/internal/i18n/locales/fr.json b/internal/i18n/locales/fr.json index b0e50c8..8ab3566 100644 --- a/internal/i18n/locales/fr.json +++ b/internal/i18n/locales/fr.json @@ -1,4 +1,9 @@ { + "TRIGGER SET": "DÉCLENCHEUR DÉFINI", + "Ctrl+S: EDIT": "Ctrl+S: MODIFIER", + "CURRENT TASK": "TÂCHE EN COURS", + "LATEST GUIDANCE": "DERNIÈRE CONSIGNE", + "STREAMING": "EN DIRECT", "╭ BREAKDOWN ╮": "╭ RÉPARTITION ╮", "╭ WINDOWS ╮": "╭ FENÊTRES DE QUOTA ╮", "CONTEXT": "CONTEXTE", @@ -413,5 +418,77 @@ "[ (L) CLEAR ALL ]": "[ (L) TOUT EFFACER ]", "[ (X) CANCEL ]": "[ (X) ANNULER ]", "[ (D) DONE ]": "[ (D) TERMINÉ ]", - "PRICES RETRIEVED ": "TARIFS RÉCUPÉRÉS " + "PRICES RETRIEVED ": "TARIFS RÉCUPÉRÉS ", + "In memory only • closing Codexometer cancels this trigger": "En mémoire uniquement • fermer annule ce déclencheur", + "Tab/Shift+Tab: fields • Wheel: scroll • Esc: back": "Tab/Shift+Tab : champs • Molette : défiler • Esc : retour", + "Will send: ": "Envoi : ", + "Not before this time; when quota + idle allow": "Pas avant ; selon quota et disponibilité", + "Will send: when quota is available": "Envoi lorsque le quota est disponible", + "and this session is idle": "et que cette session est inactive", + "Enter a message to schedule.": "Saisissez un message.", + "Keep the message within 4096 characters.": "4096 caractères maximum.", + "Remove terminal control characters.": "Supprimez les caractères de contrôle du terminal.", + "Choose a future time (delay must exceed 0).": "Choisissez une heure future.", + "Choose a time within one year.": "Choisissez une date dans l’année à venir.", + "Type your message • Enter: next field": "Saisir le message • Enter : champ suivant", + "←/→: trigger • Enter: next • q: quit": "←/→ : déclencheur • Enter : suivant • q : quitter", + "Arrows: date • PgUp/Dn: month • q: quit": "Flèches : date • PgUp/Dn : mois • q : quitter", + "Digits/arrows: time • Enter: next • q: quit": "Chiffres/flèches : heure • Enter : suivant • q : quitter", + "Enter: activate • q: quit": "Enter : activer • q : quitter", + "Enlarge the terminal before confirming.": "Agrandissez le terminal avant de confirmer.", + "Codex may start this message while you review it. Esc: cancel.": "Codex peut démarrer ce message entre-temps. Esc : annuler.", + "Send this saved prompt now? This starts Codex work.": "Envoyer ce message maintenant ? Codex commencera à travailler.", + " // Due in ": " // Dans ", + " // Time reached": " // Heure atteinte", + " // waiting for fresh quota": " // attente du quota actualisé", + " // waiting for the original account": " // attente du compte d’origine", + " // waiting for observed quota windows": " // attente des fenêtres de quota observées", + " // waiting for quota": " // attente du quota", + " // waiting for session to finish": " // attente de fin de session", + " // waiting for an eligible idle session": " // attente d’une session inactive admissible", + " // ready; awaiting dispatch": " // prêt ; en attente d’envoi", + " // open follow-ups": " // ouvrir les messages suivants", + " // %d–%d // scroll": " // %d–%d // défiler", + " // ↑↓ · Enter: edit · X: delete · Esc": " // ↑↓ · Enter : modifier · X : supprimer · Esc", + "Status: ": "État: ", + "Unnamed session": "Session sans nom", + "SCHEDULE FOLLOW-UP // ": "PLANIFIER // MESSAGES SUIVANTS // ", + "EDIT SCHEDULED FOLLOW-UP // ": "MODIFIER // PLANIFIÉ // MESSAGES SUIVANTS // ", + "AFTER QUOTA REFRESH": "APRÈS RENOUVELLEMENT DU QUOTA", + "IN…": "DANS…", + "AT…": "À…", + "AT ": "À ", + "Hours: %d": "Heures: %d", + "Minutes: %02d": "Minutes: %02d", + "Hour: ": "Heure: ", + "Minute: ": "Minute: ", + "Su Mo Tu We Th Fr Sa": "Di Lu Ma Me Je Ve Sa", + "[ BACK ]": "[ RETOUR ]", + "[ SAVE CHANGES ]": "[ ENREGISTRER ]", + "[ CONFIRM SCHEDULE ]": "[ CONFIRMER LA PLANIFICATION ]", + "[ Ctrl+S: SCHEDULE ]": "[ Ctrl+S: PLANIFIER ]", + "[ Ctrl+S: CLOSE SCHEDULE ]": "[ Ctrl+S: FERMER LA PLANIFICATION ]", + "QUEUED": "EN FILE", + "ACCEPTED // CHECKING QUEUE": "ACCEPTÉ // VÉRIFICATION DE LA FILE", + "SCHEDULED · ": "PLANIFIÉ · ", + "[NOW]": "[MAINTENANT]", + "[EDIT]": "[MODIFIER]", + "FOLLOW-UPS // %d": "MESSAGES SUIVANTS // %d", + "FOLLOW-UPS // UNAVAILABLE": "MESSAGES SUIVANTS // INDISPONIBLE", + "EDIT QUEUED MESSAGE": "MODIFIER // EN FILE", + "DELETE QUEUED MESSAGE": "SUPPRIMER // EN FILE", + "[ CONFIRM DELETE ]": "[ SUPPRIMER? ]", + "[ WAIT… ]": "[ PATIENTER… ]", + "[ CANCEL ]": "[ ANNULER ]", + "After quota recovery": "APRÈS RENOUVELLEMENT DU QUOTA", + "Not before ": "Envoi : ", + "[ Ctrl+N: SEND NOW ]": "[ Ctrl+N: ENVOYER MAINTENANT ]", + "[ Ctrl+S: EDIT ]": "[ Ctrl+S: MODIFIER ]", + "[ Ctrl+D: DELETE ]": "[ Ctrl+D: SUPPRIMER ]", + "[ Ctrl+Y: CONFIRM SEND ]": "[ Ctrl+Y: ENVOYER MAINTENANT? ]", + "[ Esc: CANCEL ]": "[ Esc: ANNULER ]", + "pending": "en attente", + "sending": "envoi", + "uncertain": "incertain", + "sent": "envoyé" } diff --git a/internal/i18n/locales/it.json b/internal/i18n/locales/it.json index b55ad09..e506b0b 100644 --- a/internal/i18n/locales/it.json +++ b/internal/i18n/locales/it.json @@ -1,4 +1,9 @@ { + "TRIGGER SET": "TRIGGER IMPOSTATO", + "Ctrl+S: EDIT": "Ctrl+S: MODIFICA", + "CURRENT TASK": "ATTIVITÀ CORRENTE", + "LATEST GUIDANCE": "ULTIME INDICAZIONI", + "STREAMING": "IN DIRETTA", "╭ BREAKDOWN ╮": "╭ RIPARTIZIONE ╮", "╭ WINDOWS ╮": "╭ FINESTRE DI QUOTA ╮", "CONTEXT": "CONTESTO", @@ -413,5 +418,77 @@ "[ (L) CLEAR ALL ]": "[ (L) CANCELLA TUTTO ]", "[ (X) CANCEL ]": "[ (X) ANNULLA ]", "[ (D) DONE ]": "[ (D) FATTO ]", - "PRICES RETRIEVED ": "PREZZI RECUPERATI " + "PRICES RETRIEVED ": "PREZZI RECUPERATI ", + "In memory only • closing Codexometer cancels this trigger": "Solo in memoria • chiudendo si annulla il trigger", + "Tab/Shift+Tab: fields • Wheel: scroll • Esc: back": "Tab/Shift+Tab: campi • Rotella: scorri • Esc: indietro", + "Will send: ": "Invio: ", + "Not before this time; when quota + idle allow": "Non prima; con quota disponibile e sessione inattiva", + "Will send: when quota is available": "Invio quando la quota è disponibile", + "and this session is idle": "e la sessione è inattiva", + "Enter a message to schedule.": "Inserisci un messaggio.", + "Keep the message within 4096 characters.": "Massimo 4096 caratteri.", + "Remove terminal control characters.": "Rimuovi i caratteri di controllo del terminale.", + "Choose a future time (delay must exceed 0).": "Scegli un orario futuro.", + "Choose a time within one year.": "Scegli un orario entro un anno.", + "Type your message • Enter: next field": "Scrivi il messaggio • Enter: campo successivo", + "←/→: trigger • Enter: next • q: quit": "←/→: trigger • Enter: avanti • q: esci", + "Arrows: date • PgUp/Dn: month • q: quit": "Frecce: data • PgUp/Dn: mese • q: esci", + "Digits/arrows: time • Enter: next • q: quit": "Cifre/frecce: ora • Enter: avanti • q: esci", + "Enter: activate • q: quit": "Enter: attiva • q: esci", + "Enlarge the terminal before confirming.": "Ingrandisci il terminale prima di confermare.", + "Codex may start this message while you review it. Esc: cancel.": "Codex potrebbe avviare il messaggio nel frattempo. Esc: annulla.", + "Send this saved prompt now? This starts Codex work.": "Inviare ora il messaggio? Codex inizierà a lavorare.", + " // Due in ": " // Tra ", + " // Time reached": " // Orario raggiunto", + " // waiting for fresh quota": " // attesa quota aggiornata", + " // waiting for the original account": " // attesa account originale", + " // waiting for observed quota windows": " // attesa finestre quota osservate", + " // waiting for quota": " // attesa quota", + " // waiting for session to finish": " // attesa fine sessione", + " // waiting for an eligible idle session": " // attesa sessione inattiva idonea", + " // ready; awaiting dispatch": " // pronto; attesa invio", + " // open follow-ups": " // apri messaggi successivi", + " // %d–%d // scroll": " // %d–%d // scorri", + " // ↑↓ · Enter: edit · X: delete · Esc": " // ↑↓ · Enter: modifica · X: elimina · Esc", + "Status: ": "Stato: ", + "Unnamed session": "Sessione senza nome", + "SCHEDULE FOLLOW-UP // ": "PROGRAMMA // MESSAGGI SUCCESSIVI // ", + "EDIT SCHEDULED FOLLOW-UP // ": "MODIFICA // PROGRAMMATO // MESSAGGI SUCCESSIVI // ", + "AFTER QUOTA REFRESH": "DOPO IL RIPRISTINO QUOTA", + "IN…": "TRA…", + "AT…": "ALLE…", + "AT ": "ALLE ", + "Hours: %d": "Ore: %d", + "Minutes: %02d": "Minuti: %02d", + "Hour: ": "Ora: ", + "Minute: ": "Minuto: ", + "Su Mo Tu We Th Fr Sa": "Do Lu Ma Me Gi Ve Sa", + "[ BACK ]": "[ INDIETRO ]", + "[ SAVE CHANGES ]": "[ SALVA MODIFICHE ]", + "[ CONFIRM SCHEDULE ]": "[ CONFERMA PROGRAMMAZIONE ]", + "[ Ctrl+S: SCHEDULE ]": "[ Ctrl+S: PROGRAMMA ]", + "[ Ctrl+S: CLOSE SCHEDULE ]": "[ Ctrl+S: CHIUDI PROGRAMMAZIONE ]", + "QUEUED": "IN CODA", + "ACCEPTED // CHECKING QUEUE": "ACCETTATO // VERIFICA CODA", + "SCHEDULED · ": "PROGRAMMATO · ", + "[NOW]": "[ORA]", + "[EDIT]": "[MODIFICA]", + "FOLLOW-UPS // %d": "MESSAGGI SUCCESSIVI // %d", + "FOLLOW-UPS // UNAVAILABLE": "MESSAGGI SUCCESSIVI // NON DISPONIBILE", + "EDIT QUEUED MESSAGE": "MODIFICA // IN CODA", + "DELETE QUEUED MESSAGE": "ELIMINA // IN CODA", + "[ CONFIRM DELETE ]": "[ ELIMINA? ]", + "[ WAIT… ]": "[ ATTENDI… ]", + "[ CANCEL ]": "[ ANNULLA ]", + "After quota recovery": "DOPO IL RIPRISTINO QUOTA", + "Not before ": "Invio: ", + "[ Ctrl+N: SEND NOW ]": "[ Ctrl+N: INVIA ORA ]", + "[ Ctrl+S: EDIT ]": "[ Ctrl+S: MODIFICA ]", + "[ Ctrl+D: DELETE ]": "[ Ctrl+D: ELIMINA ]", + "[ Ctrl+Y: CONFIRM SEND ]": "[ Ctrl+Y: INVIA ORA? ]", + "[ Esc: CANCEL ]": "[ Esc: ANNULLA ]", + "pending": "in attesa", + "sending": "invio", + "uncertain": "incerto", + "sent": "inviato" } diff --git a/internal/i18n/locales/ja.json b/internal/i18n/locales/ja.json index 9db040a..d15cb84 100644 --- a/internal/i18n/locales/ja.json +++ b/internal/i18n/locales/ja.json @@ -1,4 +1,9 @@ { + "TRIGGER SET": "トリガー設定済み", + "Ctrl+S: EDIT": "Ctrl+S: 編集", + "CURRENT TASK": "現在のタスク", + "LATEST GUIDANCE": "最新の指示", + "STREAMING": "受信中", "╭ BREAKDOWN ╮": "╭ 内訳 ╮", "╭ WINDOWS ╮": "╭ クォータ期間 ╮", "CONTEXT": "背景", @@ -413,5 +418,77 @@ "[ (L) CLEAR ALL ]": "[ (L) すべて消去 ]", "[ (X) CANCEL ]": "[ (X) キャンセル ]", "[ (D) DONE ]": "[ (D) 完了 ]", - "PRICES RETRIEVED ": "価格取得日 " + "PRICES RETRIEVED ": "価格取得日 ", + "In memory only • closing Codexometer cancels this trigger": "メモリ内のみ • 終了すると予約は取り消されます", + "Tab/Shift+Tab: fields • Wheel: scroll • Esc: back": "Tab/Shift+Tab: 項目 • ホイール: スクロール • Esc: 戻る", + "Will send: ": "送信予定: ", + "Not before this time; when quota + idle allow": "指定時刻以降、クォータと空き状況に応じて送信", + "Will send: when quota is available": "クォータが利用可能になったら送信", + "and this session is idle": "このセッションが待機中の場合", + "Enter a message to schedule.": "メッセージを入力してください。", + "Keep the message within 4096 characters.": "4096文字以内にしてください。", + "Remove terminal control characters.": "端末制御文字を削除してください。", + "Choose a future time (delay must exceed 0).": "未来の時刻を選んでください。", + "Choose a time within one year.": "1年以内の時刻を選んでください。", + "Type your message • Enter: next field": "メッセージ入力 • Enter: 次の項目", + "←/→: trigger • Enter: next • q: quit": "←/→: 条件 • Enter: 次 • q: 終了", + "Arrows: date • PgUp/Dn: month • q: quit": "矢印: 日付 • PgUp/Dn: 月 • q: 終了", + "Digits/arrows: time • Enter: next • q: quit": "数字/矢印: 時刻 • Enter: 次 • q: 終了", + "Enter: activate • q: quit": "Enter: 実行 • q: 終了", + "Enlarge the terminal before confirming.": "確定前に端末を広げてください。", + "Codex may start this message while you review it. Esc: cancel.": "確認中にCodexがこのメッセージを開始する場合があります。Esc: キャンセル", + "Send this saved prompt now? This starts Codex work.": "今すぐ送信しますか?Codexが作業を開始します。", + " // Due in ": " // 残り ", + " // Time reached": " // 予定時刻到達", + " // waiting for fresh quota": " // 最新のクォータ情報待ち", + " // waiting for the original account": " // 元のアカウント待ち", + " // waiting for observed quota windows": " // 観測済みクォータ期間待ち", + " // waiting for quota": " // クォータ待ち", + " // waiting for session to finish": " // セッション完了待ち", + " // waiting for an eligible idle session": " // 対応可能な待機セッション待ち", + " // ready; awaiting dispatch": " // 準備完了; 送信待ち", + " // open follow-ups": " // 次のメッセージを開く", + " // %d–%d // scroll": " // %d–%d // スクロール", + " // ↑↓ · Enter: edit · X: delete · Esc": " // ↑↓ · Enter: 編集 · X: 削除 · Esc", + "Status: ": "状態: ", + "Unnamed session": "名前のないセッション", + "SCHEDULE FOLLOW-UP // ": "予約 // 次のメッセージ // ", + "EDIT SCHEDULED FOLLOW-UP // ": "編集 // 予約済み // 次のメッセージ // ", + "AFTER QUOTA REFRESH": "クォータ回復後", + "IN…": "後…", + "AT…": "日時…", + "AT ": "日時 ", + "Hours: %d": "時間: %d", + "Minutes: %02d": "分: %02d", + "Hour: ": "時: ", + "Minute: ": "分: ", + "Su Mo Tu We Th Fr Sa": "日 月 火 水 木 金 土", + "[ BACK ]": "[ 戻る ]", + "[ SAVE CHANGES ]": "[ 変更を保存 ]", + "[ CONFIRM SCHEDULE ]": "[ 予約を確定 ]", + "[ Ctrl+S: SCHEDULE ]": "[ Ctrl+S: 予約 ]", + "[ Ctrl+S: CLOSE SCHEDULE ]": "[ Ctrl+S: 予約を閉じる ]", + "QUEUED": "待機中", + "ACCEPTED // CHECKING QUEUE": "受付済み // キュー確認中", + "SCHEDULED · ": "予約済み · ", + "[NOW]": "[今すぐ]", + "[EDIT]": "[編集]", + "FOLLOW-UPS // %d": "次のメッセージ // %d", + "FOLLOW-UPS // UNAVAILABLE": "次のメッセージ // 利用不可", + "EDIT QUEUED MESSAGE": "編集 // 待機中", + "DELETE QUEUED MESSAGE": "削除 // 待機中", + "[ CONFIRM DELETE ]": "[ 削除? ]", + "[ WAIT… ]": "[ 待機中… ]", + "[ CANCEL ]": "[ キャンセル ]", + "After quota recovery": "クォータ回復後", + "Not before ": "送信予定: ", + "[ Ctrl+N: SEND NOW ]": "[ Ctrl+N: 今すぐ送信 ]", + "[ Ctrl+S: EDIT ]": "[ Ctrl+S: 編集 ]", + "[ Ctrl+D: DELETE ]": "[ Ctrl+D: 削除 ]", + "[ Ctrl+Y: CONFIRM SEND ]": "[ Ctrl+Y: 今すぐ送信? ]", + "[ Esc: CANCEL ]": "[ Esc: キャンセル ]", + "pending": "保留中", + "sending": "送信中", + "uncertain": "不明", + "sent": "送信済み" } diff --git a/internal/i18n/locales/nb.json b/internal/i18n/locales/nb.json index a94015d..d84fadf 100644 --- a/internal/i18n/locales/nb.json +++ b/internal/i18n/locales/nb.json @@ -1,4 +1,9 @@ { + "TRIGGER SET": "UTLØSER ANGITT", + "Ctrl+S: EDIT": "Ctrl+S: REDIGER", + "CURRENT TASK": "GJELDENDE OPPGAVE", + "LATEST GUIDANCE": "SISTE INSTRUKSJON", + "STREAMING": "DIREKTE", "╭ BREAKDOWN ╮": "╭ FORDELING ╮", "╭ WINDOWS ╮": "╭ KVOTEPERIODER ╮", "CONTEXT": "KONTEKST", @@ -413,5 +418,77 @@ "[ (L) CLEAR ALL ]": "[ (L) TØM ALLE ]", "[ (X) CANCEL ]": "[ (X) AVBRYT ]", "[ (D) DONE ]": "[ (D) FERDIG ]", - "PRICES RETRIEVED ": "PRISER HENTET " + "PRICES RETRIEVED ": "PRISER HENTET ", + "In memory only • closing Codexometer cancels this trigger": "Bare i minnet • avslutning avbryter utløseren", + "Tab/Shift+Tab: fields • Wheel: scroll • Esc: back": "Tab/Shift+Tab: felt • Hjul: rull • Esc: tilbake", + "Will send: ": "Sendes: ", + "Not before this time; when quota + idle allow": "Tidligst da; når kvote og ledig økt tillater det", + "Will send: when quota is available": "Sendes når kvote er tilgjengelig", + "and this session is idle": "og denne økten er inaktiv", + "Enter a message to schedule.": "Skriv en melding.", + "Keep the message within 4096 characters.": "Maksimalt 4096 tegn.", + "Remove terminal control characters.": "Fjern terminalkontrolltegn.", + "Choose a future time (delay must exceed 0).": "Velg et fremtidig tidspunkt.", + "Choose a time within one year.": "Velg et tidspunkt innen ett år.", + "Type your message • Enter: next field": "Skriv melding • Enter: neste felt", + "←/→: trigger • Enter: next • q: quit": "←/→: utløser • Enter: neste • q: avslutt", + "Arrows: date • PgUp/Dn: month • q: quit": "Piler: dato • PgUp/Dn: måned • q: avslutt", + "Digits/arrows: time • Enter: next • q: quit": "Tall/piler: tid • Enter: neste • q: avslutt", + "Enter: activate • q: quit": "Enter: aktiver • q: avslutt", + "Enlarge the terminal before confirming.": "Gjør terminalen større før bekreftelse.", + "Codex may start this message while you review it. Esc: cancel.": "Codex kan starte meldingen mens du ser på den. Esc: avbryt.", + "Send this saved prompt now? This starts Codex work.": "Sende meldingen nå? Codex begynner å arbeide.", + " // Due in ": " // Om ", + " // Time reached": " // Tiden nådd", + " // waiting for fresh quota": " // venter på oppdatert kvote", + " // waiting for the original account": " // venter på opprinnelig konto", + " // waiting for observed quota windows": " // venter på observerte kvotevinduer", + " // waiting for quota": " // venter på kvote", + " // waiting for session to finish": " // venter på at økten avsluttes", + " // waiting for an eligible idle session": " // venter på egnet inaktiv økt", + " // ready; awaiting dispatch": " // klar; venter på sending", + " // open follow-ups": " // åpne oppfølginger", + " // %d–%d // scroll": " // %d–%d // rull", + " // ↑↓ · Enter: edit · X: delete · Esc": " // ↑↓ · Enter: rediger · X: slett · Esc", + "Status: ": "Status: ", + "Unnamed session": "Økt uten navn", + "SCHEDULE FOLLOW-UP // ": "PLANLEGG // OPPFØLGINGER // ", + "EDIT SCHEDULED FOLLOW-UP // ": "REDIGER // PLANLAGT // OPPFØLGINGER // ", + "AFTER QUOTA REFRESH": "ETTER KVOTENULLSTILLING", + "IN…": "OM…", + "AT…": "KL…", + "AT ": "KL ", + "Hours: %d": "Timer: %d", + "Minutes: %02d": "Minutter: %02d", + "Hour: ": "Time: ", + "Minute: ": "Minutt: ", + "Su Mo Tu We Th Fr Sa": "Sø Ma Ti On To Fr Lø", + "[ BACK ]": "[ TILBAKE ]", + "[ SAVE CHANGES ]": "[ LAGRE ENDRINGER ]", + "[ CONFIRM SCHEDULE ]": "[ BEKREFT PLAN ]", + "[ Ctrl+S: SCHEDULE ]": "[ Ctrl+S: PLANLEGG ]", + "[ Ctrl+S: CLOSE SCHEDULE ]": "[ Ctrl+S: LUKK PLANLEGGING ]", + "QUEUED": "I KØ", + "ACCEPTED // CHECKING QUEUE": "GODTATT // KONTROLLERER KØ", + "SCHEDULED · ": "PLANLAGT · ", + "[NOW]": "[NÅ]", + "[EDIT]": "[REDIGER]", + "FOLLOW-UPS // %d": "OPPFØLGINGER // %d", + "FOLLOW-UPS // UNAVAILABLE": "OPPFØLGINGER // UTILGJENGELIG", + "EDIT QUEUED MESSAGE": "REDIGER // I KØ", + "DELETE QUEUED MESSAGE": "SLETT // I KØ", + "[ CONFIRM DELETE ]": "[ SLETT? ]", + "[ WAIT… ]": "[ VENT… ]", + "[ CANCEL ]": "[ AVBRYT ]", + "After quota recovery": "ETTER KVOTENULLSTILLING", + "Not before ": "Sendes: ", + "[ Ctrl+N: SEND NOW ]": "[ Ctrl+N: SEND NÅ ]", + "[ Ctrl+S: EDIT ]": "[ Ctrl+S: REDIGER ]", + "[ Ctrl+D: DELETE ]": "[ Ctrl+D: SLETT ]", + "[ Ctrl+Y: CONFIRM SEND ]": "[ Ctrl+Y: SEND NÅ? ]", + "[ Esc: CANCEL ]": "[ Esc: AVBRYT ]", + "pending": "venter", + "sending": "sender", + "uncertain": "uklart", + "sent": "sendt" } diff --git a/internal/i18n/locales/nl.json b/internal/i18n/locales/nl.json index d9c9b3d..b68a8ca 100644 --- a/internal/i18n/locales/nl.json +++ b/internal/i18n/locales/nl.json @@ -1,4 +1,9 @@ { + "TRIGGER SET": "TRIGGER INGESTELD", + "Ctrl+S: EDIT": "Ctrl+S: BEWERKEN", + "CURRENT TASK": "HUIDIGE TAAK", + "LATEST GUIDANCE": "LAATSTE INSTRUCTIE", + "STREAMING": "LIVE", "╭ BREAKDOWN ╮": "╭ UITSPLITSING ╮", "╭ WINDOWS ╮": "╭ QUOTAVENSTERS ╮", "CONTEXT": "CONTEXT", @@ -413,5 +418,77 @@ "[ (L) CLEAR ALL ]": "[ (L) ALLES WISSEN ]", "[ (X) CANCEL ]": "[ (X) ANNULEREN ]", "[ (D) DONE ]": "[ (D) KLAAR ]", - "PRICES RETRIEVED ": "PRIJZEN OPGEHAALD " + "PRICES RETRIEVED ": "PRIJZEN OPGEHAALD ", + "In memory only • closing Codexometer cancels this trigger": "Alleen in geheugen • afsluiten annuleert deze trigger", + "Tab/Shift+Tab: fields • Wheel: scroll • Esc: back": "Tab/Shift+Tab: velden • Wiel: scrollen • Esc: terug", + "Will send: ": "Verzenden: ", + "Not before this time; when quota + idle allow": "Niet eerder; zodra quota en rust dit toelaten", + "Will send: when quota is available": "Verzenden zodra quota beschikbaar is", + "and this session is idle": "en deze sessie inactief is", + "Enter a message to schedule.": "Voer een bericht in.", + "Keep the message within 4096 characters.": "Maximaal 4096 tekens.", + "Remove terminal control characters.": "Verwijder terminalbesturingstekens.", + "Choose a future time (delay must exceed 0).": "Kies een tijd in de toekomst.", + "Choose a time within one year.": "Kies een tijd binnen één jaar.", + "Type your message • Enter: next field": "Typ bericht • Enter: volgend veld", + "←/→: trigger • Enter: next • q: quit": "←/→: trigger • Enter: volgende • q: afsluiten", + "Arrows: date • PgUp/Dn: month • q: quit": "Pijlen: datum • PgUp/Dn: maand • q: afsluiten", + "Digits/arrows: time • Enter: next • q: quit": "Cijfers/pijlen: tijd • Enter: volgende • q: afsluiten", + "Enter: activate • q: quit": "Enter: activeren • q: afsluiten", + "Enlarge the terminal before confirming.": "Vergroot de terminal vóór bevestiging.", + "Codex may start this message while you review it. Esc: cancel.": "Codex kan dit bericht intussen starten. Esc: annuleren.", + "Send this saved prompt now? This starts Codex work.": "Dit bericht nu verzenden? Codex gaat aan het werk.", + " // Due in ": " // Over ", + " // Time reached": " // Tijd bereikt", + " // waiting for fresh quota": " // wachten op actuele quota", + " // waiting for the original account": " // wachten op oorspronkelijk account", + " // waiting for observed quota windows": " // wachten op waargenomen quotavensters", + " // waiting for quota": " // wachten op quota", + " // waiting for session to finish": " // wachten op einde sessie", + " // waiting for an eligible idle session": " // wachten op geschikte inactieve sessie", + " // ready; awaiting dispatch": " // gereed; wachten op verzending", + " // open follow-ups": " // vervolgberichten openen", + " // %d–%d // scroll": " // %d–%d // scrollen", + " // ↑↓ · Enter: edit · X: delete · Esc": " // ↑↓ · Enter: bewerken · X: verwijderen · Esc", + "Status: ": "Status: ", + "Unnamed session": "Naamloze sessie", + "SCHEDULE FOLLOW-UP // ": "PLANNEN // VERVOLGBERICHTEN // ", + "EDIT SCHEDULED FOLLOW-UP // ": "BEWERKEN // GEPLAND // VERVOLGBERICHTEN // ", + "AFTER QUOTA REFRESH": "NA QUOTAHERSTEL", + "IN…": "OVER…", + "AT…": "OM…", + "AT ": "OM ", + "Hours: %d": "Uren: %d", + "Minutes: %02d": "Minuten: %02d", + "Hour: ": "Uur: ", + "Minute: ": "Minuut: ", + "Su Mo Tu We Th Fr Sa": "Zo Ma Di Wo Do Vr Za", + "[ BACK ]": "[ TERUG ]", + "[ SAVE CHANGES ]": "[ WIJZIGINGEN OPSLAAN ]", + "[ CONFIRM SCHEDULE ]": "[ PLANNING BEVESTIGEN ]", + "[ Ctrl+S: SCHEDULE ]": "[ Ctrl+S: PLANNEN ]", + "[ Ctrl+S: CLOSE SCHEDULE ]": "[ Ctrl+S: PLANNING SLUITEN ]", + "QUEUED": "IN WACHTRIJ", + "ACCEPTED // CHECKING QUEUE": "GEACCEPTEERD // WACHTRIJ CONTROLEREN", + "SCHEDULED · ": "GEPLAND · ", + "[NOW]": "[NU]", + "[EDIT]": "[BEWERKEN]", + "FOLLOW-UPS // %d": "VERVOLGBERICHTEN // %d", + "FOLLOW-UPS // UNAVAILABLE": "VERVOLGBERICHTEN // NIET BESCHIKBAAR", + "EDIT QUEUED MESSAGE": "BEWERKEN // IN WACHTRIJ", + "DELETE QUEUED MESSAGE": "VERWIJDEREN // IN WACHTRIJ", + "[ CONFIRM DELETE ]": "[ VERWIJDEREN? ]", + "[ WAIT… ]": "[ WACHT… ]", + "[ CANCEL ]": "[ ANNULEREN ]", + "After quota recovery": "NA QUOTAHERSTEL", + "Not before ": "Verzenden: ", + "[ Ctrl+N: SEND NOW ]": "[ Ctrl+N: NU VERZENDEN ]", + "[ Ctrl+S: EDIT ]": "[ Ctrl+S: BEWERKEN ]", + "[ Ctrl+D: DELETE ]": "[ Ctrl+D: VERWIJDEREN ]", + "[ Ctrl+Y: CONFIRM SEND ]": "[ Ctrl+Y: NU VERZENDEN? ]", + "[ Esc: CANCEL ]": "[ Esc: ANNULEREN ]", + "pending": "wachtend", + "sending": "verzenden", + "uncertain": "onzeker", + "sent": "verzonden" } diff --git a/internal/i18n/locales/pt-BR.json b/internal/i18n/locales/pt-BR.json index d3f2154..4bd53aa 100644 --- a/internal/i18n/locales/pt-BR.json +++ b/internal/i18n/locales/pt-BR.json @@ -1,4 +1,9 @@ { + "TRIGGER SET": "GATILHO DEFINIDO", + "Ctrl+S: EDIT": "Ctrl+S: EDITAR", + "CURRENT TASK": "TAREFA ATUAL", + "LATEST GUIDANCE": "ÚLTIMA ORIENTAÇÃO", + "STREAMING": "AO VIVO", "╭ BREAKDOWN ╮": "╭ DETALHAMENTO ╮", "╭ WINDOWS ╮": "╭ JANELAS DE COTA ╮", "CONTEXT": "CONTEXTO", @@ -413,5 +418,77 @@ "[ (L) CLEAR ALL ]": "[ (L) LIMPAR TODOS ]", "[ (X) CANCEL ]": "[ (X) CANCELAR ]", "[ (D) DONE ]": "[ (D) CONCLUÍDO ]", - "PRICES RETRIEVED ": "PREÇOS CONSULTADOS " + "PRICES RETRIEVED ": "PREÇOS CONSULTADOS ", + "In memory only • closing Codexometer cancels this trigger": "Só na memória • fechar cancela este gatilho", + "Tab/Shift+Tab: fields • Wheel: scroll • Esc: back": "Tab/Shift+Tab: campos • Roda: rolar • Esc: voltar", + "Will send: ": "Envio: ", + "Not before this time; when quota + idle allow": "Não antes; quando houver cota e a sessão estiver ociosa", + "Will send: when quota is available": "Enviar quando houver cota disponível", + "and this session is idle": "e esta sessão estiver ociosa", + "Enter a message to schedule.": "Digite uma mensagem.", + "Keep the message within 4096 characters.": "No máximo 4096 caracteres.", + "Remove terminal control characters.": "Remova os caracteres de controle do terminal.", + "Choose a future time (delay must exceed 0).": "Escolha um horário futuro.", + "Choose a time within one year.": "Escolha um horário dentro de um ano.", + "Type your message • Enter: next field": "Digite a mensagem • Enter: próximo campo", + "←/→: trigger • Enter: next • q: quit": "←/→: gatilho • Enter: próximo • q: sair", + "Arrows: date • PgUp/Dn: month • q: quit": "Setas: data • PgUp/Dn: mês • q: sair", + "Digits/arrows: time • Enter: next • q: quit": "Dígitos/setas: hora • Enter: próximo • q: sair", + "Enter: activate • q: quit": "Enter: ativar • q: sair", + "Enlarge the terminal before confirming.": "Amplie o terminal antes de confirmar.", + "Codex may start this message while you review it. Esc: cancel.": "Codex pode iniciar a mensagem durante a revisão. Esc: cancelar.", + "Send this saved prompt now? This starts Codex work.": "Enviar a mensagem agora? Codex começará a trabalhar.", + " // Due in ": " // Em ", + " // Time reached": " // Horário atingido", + " // waiting for fresh quota": " // aguardando cota atualizada", + " // waiting for the original account": " // aguardando conta original", + " // waiting for observed quota windows": " // aguardando janelas de cota observadas", + " // waiting for quota": " // aguardando cota", + " // waiting for session to finish": " // aguardando fim da sessão", + " // waiting for an eligible idle session": " // aguardando sessão ociosa elegível", + " // ready; awaiting dispatch": " // pronto; aguardando envio", + " // open follow-ups": " // abrir mensagens seguintes", + " // %d–%d // scroll": " // %d–%d // rolar", + " // ↑↓ · Enter: edit · X: delete · Esc": " // ↑↓ · Enter: editar · X: excluir · Esc", + "Status: ": "Status: ", + "Unnamed session": "Sessão sem nome", + "SCHEDULE FOLLOW-UP // ": "AGENDAR // MENSAGENS SEGUINTES // ", + "EDIT SCHEDULED FOLLOW-UP // ": "EDITAR // AGENDADO // MENSAGENS SEGUINTES // ", + "AFTER QUOTA REFRESH": "APÓS RENOVAÇÃO DA COTA", + "IN…": "EM…", + "AT…": "ÀS…", + "AT ": "ÀS ", + "Hours: %d": "Horas: %d", + "Minutes: %02d": "Minutos: %02d", + "Hour: ": "Hora: ", + "Minute: ": "Minuto: ", + "Su Mo Tu We Th Fr Sa": "Do Se Te Qu Qi Se Sá", + "[ BACK ]": "[ VOLTAR ]", + "[ SAVE CHANGES ]": "[ SALVAR ALTERAÇÕES ]", + "[ CONFIRM SCHEDULE ]": "[ CONFIRMAR AGENDAMENTO ]", + "[ Ctrl+S: SCHEDULE ]": "[ Ctrl+S: AGENDAR ]", + "[ Ctrl+S: CLOSE SCHEDULE ]": "[ Ctrl+S: FECHAR AGENDAMENTO ]", + "QUEUED": "NA FILA", + "ACCEPTED // CHECKING QUEUE": "ACEITO // VERIFICANDO FILA", + "SCHEDULED · ": "AGENDADO · ", + "[NOW]": "[AGORA]", + "[EDIT]": "[EDITAR]", + "FOLLOW-UPS // %d": "MENSAGENS SEGUINTES // %d", + "FOLLOW-UPS // UNAVAILABLE": "MENSAGENS SEGUINTES // INDISPONÍVEL", + "EDIT QUEUED MESSAGE": "EDITAR // NA FILA", + "DELETE QUEUED MESSAGE": "EXCLUIR // NA FILA", + "[ CONFIRM DELETE ]": "[ EXCLUIR? ]", + "[ WAIT… ]": "[ AGUARDE… ]", + "[ CANCEL ]": "[ CANCELAR ]", + "After quota recovery": "APÓS RENOVAÇÃO DA COTA", + "Not before ": "Envio: ", + "[ Ctrl+N: SEND NOW ]": "[ Ctrl+N: ENVIAR AGORA ]", + "[ Ctrl+S: EDIT ]": "[ Ctrl+S: EDITAR ]", + "[ Ctrl+D: DELETE ]": "[ Ctrl+D: EXCLUIR ]", + "[ Ctrl+Y: CONFIRM SEND ]": "[ Ctrl+Y: ENVIAR AGORA? ]", + "[ Esc: CANCEL ]": "[ Esc: CANCELAR ]", + "pending": "pendente", + "sending": "enviando", + "uncertain": "incerto", + "sent": "enviado" } diff --git a/internal/i18n/locales/pt-PT.json b/internal/i18n/locales/pt-PT.json index 9a47d3c..db9f1ec 100644 --- a/internal/i18n/locales/pt-PT.json +++ b/internal/i18n/locales/pt-PT.json @@ -1,4 +1,9 @@ { + "TRIGGER SET": "ACIONADOR DEFINIDO", + "Ctrl+S: EDIT": "Ctrl+S: EDITAR", + "CURRENT TASK": "TAREFA ATUAL", + "LATEST GUIDANCE": "ÚLTIMA ORIENTAÇÃO", + "STREAMING": "EM DIRETO", "╭ BREAKDOWN ╮": "╭ DETALHE ╮", "╭ WINDOWS ╮": "╭ JANELAS DE QUOTA ╮", "CONTEXT": "CONTEXTO", @@ -413,5 +418,77 @@ "[ (L) CLEAR ALL ]": "[ (L) LIMPAR TODOS ]", "[ (X) CANCEL ]": "[ (X) CANCELAR ]", "[ (D) DONE ]": "[ (D) CONCLUÍDO ]", - "PRICES RETRIEVED ": "PREÇOS CONSULTADOS " + "PRICES RETRIEVED ": "PREÇOS CONSULTADOS ", + "In memory only • closing Codexometer cancels this trigger": "Só na memória • fechar cancela este acionador", + "Tab/Shift+Tab: fields • Wheel: scroll • Esc: back": "Tab/Shift+Tab: campos • Roda: deslocar • Esc: voltar", + "Will send: ": "Envio: ", + "Not before this time; when quota + idle allow": "Não antes; quando houver quota e a sessão estiver livre", + "Will send: when quota is available": "Enviar quando houver quota disponível", + "and this session is idle": "e esta sessão estiver livre", + "Enter a message to schedule.": "Introduza uma mensagem.", + "Keep the message within 4096 characters.": "No máximo 4096 carateres.", + "Remove terminal control characters.": "Remova os carateres de controlo do terminal.", + "Choose a future time (delay must exceed 0).": "Escolha uma hora futura.", + "Choose a time within one year.": "Escolha uma hora dentro de um ano.", + "Type your message • Enter: next field": "Escreva a mensagem • Enter: próximo campo", + "←/→: trigger • Enter: next • q: quit": "←/→: acionador • Enter: próximo • q: sair", + "Arrows: date • PgUp/Dn: month • q: quit": "Setas: data • PgUp/Dn: mês • q: sair", + "Digits/arrows: time • Enter: next • q: quit": "Dígitos/setas: hora • Enter: próximo • q: sair", + "Enter: activate • q: quit": "Enter: ativar • q: sair", + "Enlarge the terminal before confirming.": "Amplie o terminal antes de confirmar.", + "Codex may start this message while you review it. Esc: cancel.": "Codex pode iniciar a mensagem durante a revisão. Esc: cancelar.", + "Send this saved prompt now? This starts Codex work.": "Enviar a mensagem agora? Codex começará a trabalhar.", + " // Due in ": " // Daqui a ", + " // Time reached": " // Hora atingida", + " // waiting for fresh quota": " // à espera de quota atualizada", + " // waiting for the original account": " // à espera da conta original", + " // waiting for observed quota windows": " // à espera das janelas de quota observadas", + " // waiting for quota": " // à espera de quota", + " // waiting for session to finish": " // à espera do fim da sessão", + " // waiting for an eligible idle session": " // à espera de sessão livre elegível", + " // ready; awaiting dispatch": " // pronto; à espera de envio", + " // open follow-ups": " // abrir mensagens seguintes", + " // %d–%d // scroll": " // %d–%d // deslocar", + " // ↑↓ · Enter: edit · X: delete · Esc": " // ↑↓ · Enter: editar · X: eliminar · Esc", + "Status: ": "Estado: ", + "Unnamed session": "Sessão sem nome", + "SCHEDULE FOLLOW-UP // ": "AGENDAR // MENSAGENS SEGUINTES // ", + "EDIT SCHEDULED FOLLOW-UP // ": "EDITAR // AGENDADO // MENSAGENS SEGUINTES // ", + "AFTER QUOTA REFRESH": "APÓS RENOVAÇÃO DA QUOTA", + "IN…": "DAQUI A…", + "AT…": "ÀS…", + "AT ": "ÀS ", + "Hours: %d": "Horas: %d", + "Minutes: %02d": "Minutos: %02d", + "Hour: ": "Hora: ", + "Minute: ": "Minuto: ", + "Su Mo Tu We Th Fr Sa": "Do Se Te Qu Qi Se Sá", + "[ BACK ]": "[ VOLTAR ]", + "[ SAVE CHANGES ]": "[ GUARDAR ALTERAÇÕES ]", + "[ CONFIRM SCHEDULE ]": "[ CONFIRMAR AGENDAMENTO ]", + "[ Ctrl+S: SCHEDULE ]": "[ Ctrl+S: AGENDAR ]", + "[ Ctrl+S: CLOSE SCHEDULE ]": "[ Ctrl+S: FECHAR AGENDAMENTO ]", + "QUEUED": "EM FILA", + "ACCEPTED // CHECKING QUEUE": "ACEITE // A VERIFICAR FILA", + "SCHEDULED · ": "AGENDADO · ", + "[NOW]": "[AGORA]", + "[EDIT]": "[EDITAR]", + "FOLLOW-UPS // %d": "MENSAGENS SEGUINTES // %d", + "FOLLOW-UPS // UNAVAILABLE": "MENSAGENS SEGUINTES // INDISPONÍVEL", + "EDIT QUEUED MESSAGE": "EDITAR // EM FILA", + "DELETE QUEUED MESSAGE": "ELIMINAR // EM FILA", + "[ CONFIRM DELETE ]": "[ ELIMINAR? ]", + "[ WAIT… ]": "[ AGUARDE… ]", + "[ CANCEL ]": "[ CANCELAR ]", + "After quota recovery": "APÓS RENOVAÇÃO DA QUOTA", + "Not before ": "Envio: ", + "[ Ctrl+N: SEND NOW ]": "[ Ctrl+N: ENVIAR AGORA ]", + "[ Ctrl+S: EDIT ]": "[ Ctrl+S: EDITAR ]", + "[ Ctrl+D: DELETE ]": "[ Ctrl+D: ELIMINAR ]", + "[ Ctrl+Y: CONFIRM SEND ]": "[ Ctrl+Y: ENVIAR AGORA? ]", + "[ Esc: CANCEL ]": "[ Esc: CANCELAR ]", + "pending": "pendente", + "sending": "a enviar", + "uncertain": "incerto", + "sent": "enviado" } diff --git a/internal/i18n/locales/ru.json b/internal/i18n/locales/ru.json index bb3c930..2f25935 100644 --- a/internal/i18n/locales/ru.json +++ b/internal/i18n/locales/ru.json @@ -1,4 +1,9 @@ { + "TRIGGER SET": "ТРИГГЕР ЗАДАН", + "Ctrl+S: EDIT": "Ctrl+S: ИЗМЕНИТЬ", + "CURRENT TASK": "ТЕКУЩАЯ ЗАДАЧА", + "LATEST GUIDANCE": "ПОСЛЕДНИЕ УКАЗАНИЯ", + "STREAMING": "ПОТОК", "╭ BREAKDOWN ╮": "╭ РАЗБИВКА ╮", "╭ WINDOWS ╮": "╭ ОКНА КВОТЫ ╮", "CONTEXT": "КОНТЕКСТ", @@ -413,5 +418,77 @@ "[ (L) CLEAR ALL ]": "[ (L) ОЧИСТИТЬ ВСЁ ]", "[ (X) CANCEL ]": "[ (X) ОТМЕНА ]", "[ (D) DONE ]": "[ (D) ГОТОВО ]", - "PRICES RETRIEVED ": "ЦЕНЫ ПОЛУЧЕНЫ " + "PRICES RETRIEVED ": "ЦЕНЫ ПОЛУЧЕНЫ ", + "In memory only • closing Codexometer cancels this trigger": "Только в памяти • закрытие отменит этот триггер", + "Tab/Shift+Tab: fields • Wheel: scroll • Esc: back": "Tab/Shift+Tab: поля • Колесо: прокрутка • Esc: назад", + "Will send: ": "Отправка: ", + "Not before this time; when quota + idle allow": "Не раньше; при наличии квоты и свободной сессии", + "Will send: when quota is available": "Отправка после восстановления квоты", + "and this session is idle": "когда эта сессия не занята", + "Enter a message to schedule.": "Введите сообщение.", + "Keep the message within 4096 characters.": "Не более 4096 символов.", + "Remove terminal control characters.": "Удалите управляющие символы терминала.", + "Choose a future time (delay must exceed 0).": "Выберите время в будущем.", + "Choose a time within one year.": "Выберите время в пределах года.", + "Type your message • Enter: next field": "Введите сообщение • Enter: следующее поле", + "←/→: trigger • Enter: next • q: quit": "←/→: триггер • Enter: далее • q: выход", + "Arrows: date • PgUp/Dn: month • q: quit": "Стрелки: дата • PgUp/Dn: месяц • q: выход", + "Digits/arrows: time • Enter: next • q: quit": "Цифры/стрелки: время • Enter: далее • q: выход", + "Enter: activate • q: quit": "Enter: выполнить • q: выход", + "Enlarge the terminal before confirming.": "Увеличьте терминал перед подтверждением.", + "Codex may start this message while you review it. Esc: cancel.": "Codex может начать обработку во время проверки. Esc: отмена.", + "Send this saved prompt now? This starts Codex work.": "Отправить сообщение сейчас? Codex начнёт работу.", + " // Due in ": " // Через ", + " // Time reached": " // Время наступило", + " // waiting for fresh quota": " // ожидание свежих данных квоты", + " // waiting for the original account": " // ожидание исходного аккаунта", + " // waiting for observed quota windows": " // ожидание наблюдаемых окон квоты", + " // waiting for quota": " // ожидание квоты", + " // waiting for session to finish": " // ожидание завершения сессии", + " // waiting for an eligible idle session": " // ожидание подходящей свободной сессии", + " // ready; awaiting dispatch": " // готово; ожидание отправки", + " // open follow-ups": " // открыть следующие сообщения", + " // %d–%d // scroll": " // %d–%d // прокрутка", + " // ↑↓ · Enter: edit · X: delete · Esc": " // ↑↓ · Enter: изменить · X: удалить · Esc", + "Status: ": "Статус: ", + "Unnamed session": "Сессия без имени", + "SCHEDULE FOLLOW-UP // ": "ЗАПЛАНИРОВАТЬ // СЛЕДУЮЩИЕ СООБЩЕНИЯ // ", + "EDIT SCHEDULED FOLLOW-UP // ": "ИЗМЕНИТЬ // ЗАПЛАНИРОВАНО // СЛЕДУЮЩИЕ СООБЩЕНИЯ // ", + "AFTER QUOTA REFRESH": "ПОСЛЕ ОБНОВЛЕНИЯ КВОТЫ", + "IN…": "ЧЕРЕЗ…", + "AT…": "В…", + "AT ": "В ", + "Hours: %d": "Часы: %d", + "Minutes: %02d": "Минуты: %02d", + "Hour: ": "Час: ", + "Minute: ": "Минута: ", + "Su Mo Tu We Th Fr Sa": "Вс Пн Вт Ср Чт Пт Сб", + "[ BACK ]": "[ НАЗАД ]", + "[ SAVE CHANGES ]": "[ СОХРАНИТЬ ]", + "[ CONFIRM SCHEDULE ]": "[ ПОДТВЕРДИТЬ ПЛАН ]", + "[ Ctrl+S: SCHEDULE ]": "[ Ctrl+S: ЗАПЛАНИРОВАТЬ ]", + "[ Ctrl+S: CLOSE SCHEDULE ]": "[ Ctrl+S: ЗАКРЫТЬ ПЛАНИРОВЩИК ]", + "QUEUED": "В ОЧЕРЕДИ", + "ACCEPTED // CHECKING QUEUE": "ПРИНЯТО // ПРОВЕРКА ОЧЕРЕДИ", + "SCHEDULED · ": "ЗАПЛАНИРОВАНО · ", + "[NOW]": "[СЕЙЧАС]", + "[EDIT]": "[ИЗМЕНИТЬ]", + "FOLLOW-UPS // %d": "СЛЕДУЮЩИЕ СООБЩЕНИЯ // %d", + "FOLLOW-UPS // UNAVAILABLE": "СЛЕДУЮЩИЕ СООБЩЕНИЯ // НЕДОСТУПНО", + "EDIT QUEUED MESSAGE": "ИЗМЕНИТЬ // В ОЧЕРЕДИ", + "DELETE QUEUED MESSAGE": "УДАЛИТЬ // В ОЧЕРЕДИ", + "[ CONFIRM DELETE ]": "[ УДАЛИТЬ? ]", + "[ WAIT… ]": "[ ОЖИДАНИЕ… ]", + "[ CANCEL ]": "[ ОТМЕНА ]", + "After quota recovery": "ПОСЛЕ ОБНОВЛЕНИЯ КВОТЫ", + "Not before ": "Отправка: ", + "[ Ctrl+N: SEND NOW ]": "[ Ctrl+N: ОТПРАВИТЬ СЕЙЧАС ]", + "[ Ctrl+S: EDIT ]": "[ Ctrl+S: ИЗМЕНИТЬ ]", + "[ Ctrl+D: DELETE ]": "[ Ctrl+D: УДАЛИТЬ ]", + "[ Ctrl+Y: CONFIRM SEND ]": "[ Ctrl+Y: ОТПРАВИТЬ СЕЙЧАС? ]", + "[ Esc: CANCEL ]": "[ Esc: ОТМЕНА ]", + "pending": "ожидание", + "sending": "отправка", + "uncertain": "неизвестно", + "sent": "отправлено" } diff --git a/internal/i18n/locales/sv.json b/internal/i18n/locales/sv.json index a1e54d4..692bbc6 100644 --- a/internal/i18n/locales/sv.json +++ b/internal/i18n/locales/sv.json @@ -1,4 +1,9 @@ { + "TRIGGER SET": "UTLÖSARE INSTÄLLD", + "Ctrl+S: EDIT": "Ctrl+S: REDIGERA", + "CURRENT TASK": "AKTUELL UPPGIFT", + "LATEST GUIDANCE": "SENASTE INSTRUKTION", + "STREAMING": "DIREKT", "╭ BREAKDOWN ╮": "╭ FÖRDELNING ╮", "╭ WINDOWS ╮": "╭ KVOTPERIODER ╮", "CONTEXT": "KONTEXT", @@ -413,5 +418,77 @@ "[ (L) CLEAR ALL ]": "[ (L) RENSA ALLA ]", "[ (X) CANCEL ]": "[ (X) AVBRYT ]", "[ (D) DONE ]": "[ (D) KLART ]", - "PRICES RETRIEVED ": "PRISER HÄMTADE " + "PRICES RETRIEVED ": "PRISER HÄMTADE ", + "In memory only • closing Codexometer cancels this trigger": "Endast i minnet • avslut avbryter utlösaren", + "Tab/Shift+Tab: fields • Wheel: scroll • Esc: back": "Tab/Shift+Tab: fält • Hjul: rulla • Esc: tillbaka", + "Will send: ": "Skickas: ", + "Not before this time; when quota + idle allow": "Tidigast då; när kvot och ledig session tillåter", + "Will send: when quota is available": "Skickas när kvot finns", + "and this session is idle": "och sessionen är inaktiv", + "Enter a message to schedule.": "Skriv ett meddelande.", + "Keep the message within 4096 characters.": "Högst 4096 tecken.", + "Remove terminal control characters.": "Ta bort terminalstyrtecken.", + "Choose a future time (delay must exceed 0).": "Välj en framtida tid.", + "Choose a time within one year.": "Välj en tid inom ett år.", + "Type your message • Enter: next field": "Skriv meddelande • Enter: nästa fält", + "←/→: trigger • Enter: next • q: quit": "←/→: utlösare • Enter: nästa • q: avsluta", + "Arrows: date • PgUp/Dn: month • q: quit": "Pilar: datum • PgUp/Dn: månad • q: avsluta", + "Digits/arrows: time • Enter: next • q: quit": "Siffror/pilar: tid • Enter: nästa • q: avsluta", + "Enter: activate • q: quit": "Enter: aktivera • q: avsluta", + "Enlarge the terminal before confirming.": "Förstora terminalen före bekräftelse.", + "Codex may start this message while you review it. Esc: cancel.": "Codex kan starta meddelandet under granskningen. Esc: avbryt.", + "Send this saved prompt now? This starts Codex work.": "Skicka meddelandet nu? Codex börjar arbeta.", + " // Due in ": " // Om ", + " // Time reached": " // Tiden nådd", + " // waiting for fresh quota": " // väntar på aktuell kvot", + " // waiting for the original account": " // väntar på ursprungligt konto", + " // waiting for observed quota windows": " // väntar på observerade kvotfönster", + " // waiting for quota": " // väntar på kvot", + " // waiting for session to finish": " // väntar på sessionsslut", + " // waiting for an eligible idle session": " // väntar på lämplig inaktiv session", + " // ready; awaiting dispatch": " // redo; väntar på utskick", + " // open follow-ups": " // öppna uppföljningar", + " // %d–%d // scroll": " // %d–%d // rulla", + " // ↑↓ · Enter: edit · X: delete · Esc": " // ↑↓ · Enter: redigera · X: radera · Esc", + "Status: ": "Status: ", + "Unnamed session": "Namnlös session", + "SCHEDULE FOLLOW-UP // ": "SCHEMALÄGG // UPPFÖLJNINGAR // ", + "EDIT SCHEDULED FOLLOW-UP // ": "REDIGERA // SCHEMALAGD // UPPFÖLJNINGAR // ", + "AFTER QUOTA REFRESH": "EFTER KVOTÅTERSTÄLLNING", + "IN…": "OM…", + "AT…": "KL…", + "AT ": "KL ", + "Hours: %d": "Timmar: %d", + "Minutes: %02d": "Minuter: %02d", + "Hour: ": "Timme: ", + "Minute: ": "Minut: ", + "Su Mo Tu We Th Fr Sa": "Sö Må Ti On To Fr Lö", + "[ BACK ]": "[ TILLBAKA ]", + "[ SAVE CHANGES ]": "[ SPARA ÄNDRINGAR ]", + "[ CONFIRM SCHEDULE ]": "[ BEKRÄFTA SCHEMA ]", + "[ Ctrl+S: SCHEDULE ]": "[ Ctrl+S: SCHEMALÄGG ]", + "[ Ctrl+S: CLOSE SCHEDULE ]": "[ Ctrl+S: STÄNG SCHEMA ]", + "QUEUED": "KÖAD", + "ACCEPTED // CHECKING QUEUE": "GODKÄND // KONTROLLERAR KÖ", + "SCHEDULED · ": "SCHEMALAGD · ", + "[NOW]": "[NU]", + "[EDIT]": "[REDIGERA]", + "FOLLOW-UPS // %d": "UPPFÖLJNINGAR // %d", + "FOLLOW-UPS // UNAVAILABLE": "UPPFÖLJNINGAR // OTILLGÄNGLIG", + "EDIT QUEUED MESSAGE": "REDIGERA // KÖAD", + "DELETE QUEUED MESSAGE": "RADERA // KÖAD", + "[ CONFIRM DELETE ]": "[ RADERA? ]", + "[ WAIT… ]": "[ VÄNTA… ]", + "[ CANCEL ]": "[ AVBRYT ]", + "After quota recovery": "EFTER KVOTÅTERSTÄLLNING", + "Not before ": "Skickas: ", + "[ Ctrl+N: SEND NOW ]": "[ Ctrl+N: SKICKA NU ]", + "[ Ctrl+S: EDIT ]": "[ Ctrl+S: REDIGERA ]", + "[ Ctrl+D: DELETE ]": "[ Ctrl+D: RADERA ]", + "[ Ctrl+Y: CONFIRM SEND ]": "[ Ctrl+Y: SKICKA NU? ]", + "[ Esc: CANCEL ]": "[ Esc: AVBRYT ]", + "pending": "väntande", + "sending": "skickar", + "uncertain": "oklart", + "sent": "skickat" } diff --git a/internal/i18n/locales/tr.json b/internal/i18n/locales/tr.json index 4660b70..1715449 100644 --- a/internal/i18n/locales/tr.json +++ b/internal/i18n/locales/tr.json @@ -1,4 +1,9 @@ { + "TRIGGER SET": "TETİKLEYİCİ AYARLANDI", + "Ctrl+S: EDIT": "Ctrl+S: DÜZENLE", + "CURRENT TASK": "GEÇERLİ GÖREV", + "LATEST GUIDANCE": "SON YÖNLENDİRME", + "STREAMING": "CANLI", "╭ BREAKDOWN ╮": "╭ DÖKÜM ╮", "╭ WINDOWS ╮": "╭ KOTA DÖNEMLERİ ╮", "CONTEXT": "BAĞLAM", @@ -413,5 +418,77 @@ "[ (L) CLEAR ALL ]": "[ (L) TÜMÜNÜ TEMİZLE ]", "[ (X) CANCEL ]": "[ (X) İPTAL ]", "[ (D) DONE ]": "[ (D) BİTTİ ]", - "PRICES RETRIEVED ": "FİYATLAR ALINDI " + "PRICES RETRIEVED ": "FİYATLAR ALINDI ", + "In memory only • closing Codexometer cancels this trigger": "Yalnızca bellekte • kapatmak tetikleyiciyi iptal eder", + "Tab/Shift+Tab: fields • Wheel: scroll • Esc: back": "Tab/Shift+Tab: alanlar • Tekerlek: kaydır • Esc: geri", + "Will send: ": "Gönderim: ", + "Not before this time; when quota + idle allow": "Bu zamandan önce değil; kota ve boş oturum gerektirir", + "Will send: when quota is available": "Kota kullanılabilir olduğunda gönder", + "and this session is idle": "ve bu oturum boş olduğunda", + "Enter a message to schedule.": "Bir mesaj girin.", + "Keep the message within 4096 characters.": "En fazla 4096 karakter.", + "Remove terminal control characters.": "Terminal kontrol karakterlerini kaldırın.", + "Choose a future time (delay must exceed 0).": "Gelecekte bir zaman seçin.", + "Choose a time within one year.": "Bir yıl içinde bir zaman seçin.", + "Type your message • Enter: next field": "Mesaj yazın • Enter: sonraki alan", + "←/→: trigger • Enter: next • q: quit": "←/→: tetikleyici • Enter: sonraki • q: çık", + "Arrows: date • PgUp/Dn: month • q: quit": "Oklar: tarih • PgUp/Dn: ay • q: çık", + "Digits/arrows: time • Enter: next • q: quit": "Rakamlar/oklar: saat • Enter: sonraki • q: çık", + "Enter: activate • q: quit": "Enter: etkinleştir • q: çık", + "Enlarge the terminal before confirming.": "Onaylamadan önce terminali büyütün.", + "Codex may start this message while you review it. Esc: cancel.": "Siz incelerken Codex mesajı başlatabilir. Esc: iptal.", + "Send this saved prompt now? This starts Codex work.": "Mesaj şimdi gönderilsin mi? Codex çalışmaya başlar.", + " // Due in ": " // Kalan ", + " // Time reached": " // Zaman geldi", + " // waiting for fresh quota": " // güncel kota bekleniyor", + " // waiting for the original account": " // ilk hesap bekleniyor", + " // waiting for observed quota windows": " // gözlemlenen kota pencereleri bekleniyor", + " // waiting for quota": " // kota bekleniyor", + " // waiting for session to finish": " // oturumun bitmesi bekleniyor", + " // waiting for an eligible idle session": " // uygun boş oturum bekleniyor", + " // ready; awaiting dispatch": " // hazır; gönderim bekleniyor", + " // open follow-ups": " // takip mesajlarını aç", + " // %d–%d // scroll": " // %d–%d // kaydır", + " // ↑↓ · Enter: edit · X: delete · Esc": " // ↑↓ · Enter: düzenle · X: sil · Esc", + "Status: ": "Durum: ", + "Unnamed session": "Adsız oturum", + "SCHEDULE FOLLOW-UP // ": "PLANLA // TAKİP MESAJLARI // ", + "EDIT SCHEDULED FOLLOW-UP // ": "DÜZENLE // PLANLANDI // TAKİP MESAJLARI // ", + "AFTER QUOTA REFRESH": "KOTA YENİLENDİKTEN SONRA", + "IN…": "SONRA…", + "AT…": "SAAT…", + "AT ": "SAAT ", + "Hours: %d": "Saat: %d", + "Minutes: %02d": "Dakika: %02d", + "Hour: ": "Saat: ", + "Minute: ": "Dakika: ", + "Su Mo Tu We Th Fr Sa": "Pa Pt Sa Ça Pe Cu Ct", + "[ BACK ]": "[ GERİ ]", + "[ SAVE CHANGES ]": "[ DEĞİŞİKLİKLERİ KAYDET ]", + "[ CONFIRM SCHEDULE ]": "[ PLANI ONAYLA ]", + "[ Ctrl+S: SCHEDULE ]": "[ Ctrl+S: PLANLA ]", + "[ Ctrl+S: CLOSE SCHEDULE ]": "[ Ctrl+S: PLANLAMAYI KAPAT ]", + "QUEUED": "SIRADA", + "ACCEPTED // CHECKING QUEUE": "KABUL EDİLDİ // KUYRUK KONTROLÜ", + "SCHEDULED · ": "PLANLANDI · ", + "[NOW]": "[ŞİMDİ]", + "[EDIT]": "[DÜZENLE]", + "FOLLOW-UPS // %d": "TAKİP MESAJLARI // %d", + "FOLLOW-UPS // UNAVAILABLE": "TAKİP MESAJLARI // KULLANILAMIYOR", + "EDIT QUEUED MESSAGE": "DÜZENLE // SIRADA", + "DELETE QUEUED MESSAGE": "SİL // SIRADA", + "[ CONFIRM DELETE ]": "[ SİL? ]", + "[ WAIT… ]": "[ BEKLEYİN… ]", + "[ CANCEL ]": "[ İPTAL ]", + "After quota recovery": "KOTA YENİLENDİKTEN SONRA", + "Not before ": "Gönderim: ", + "[ Ctrl+N: SEND NOW ]": "[ Ctrl+N: ŞİMDİ GÖNDER ]", + "[ Ctrl+S: EDIT ]": "[ Ctrl+S: DÜZENLE ]", + "[ Ctrl+D: DELETE ]": "[ Ctrl+D: SİL ]", + "[ Ctrl+Y: CONFIRM SEND ]": "[ Ctrl+Y: ŞİMDİ GÖNDER? ]", + "[ Esc: CANCEL ]": "[ Esc: İPTAL ]", + "pending": "bekliyor", + "sending": "gönderiliyor", + "uncertain": "belirsiz", + "sent": "gönderildi" } diff --git a/internal/i18n/locales/zh-Hans.json b/internal/i18n/locales/zh-Hans.json index 4daf9d2..ab580d2 100644 --- a/internal/i18n/locales/zh-Hans.json +++ b/internal/i18n/locales/zh-Hans.json @@ -1,4 +1,9 @@ { + "TRIGGER SET": "已设触发器", + "Ctrl+S: EDIT": "Ctrl+S: 编辑", + "CURRENT TASK": "当前任务", + "LATEST GUIDANCE": "最新指示", + "STREAMING": "流式输出", "╭ BREAKDOWN ╮": "╭ 用量明细 ╮", "╭ WINDOWS ╮": "╭ 配额周期 ╮", "CONTEXT": "背景", @@ -413,5 +418,77 @@ "[ (L) CLEAR ALL ]": "[ (L) 全部清除 ]", "[ (X) CANCEL ]": "[ (X) 取消 ]", "[ (D) DONE ]": "[ (D) 完成 ]", - "PRICES RETRIEVED ": "价格获取日期 " + "PRICES RETRIEVED ": "价格获取日期 ", + "In memory only • closing Codexometer cancels this trigger": "仅保存在内存中 • 关闭将取消此触发器", + "Tab/Shift+Tab: fields • Wheel: scroll • Esc: back": "Tab/Shift+Tab: 字段 • 滚轮: 滚动 • Esc: 返回", + "Will send: ": "发送时间: ", + "Not before this time; when quota + idle allow": "不早于此时;需配额可用且会话空闲", + "Will send: when quota is available": "配额可用时发送", + "and this session is idle": "且此会话空闲", + "Enter a message to schedule.": "请输入消息。", + "Keep the message within 4096 characters.": "最多4096个字符。", + "Remove terminal control characters.": "请移除终端控制字符。", + "Choose a future time (delay must exceed 0).": "请选择未来时间。", + "Choose a time within one year.": "请选择一年内的时间。", + "Type your message • Enter: next field": "输入消息 • Enter: 下一字段", + "←/→: trigger • Enter: next • q: quit": "←/→: 触发条件 • Enter: 下一项 • q: 退出", + "Arrows: date • PgUp/Dn: month • q: quit": "方向键: 日期 • PgUp/Dn: 月份 • q: 退出", + "Digits/arrows: time • Enter: next • q: quit": "数字/方向键: 时间 • Enter: 下一项 • q: 退出", + "Enter: activate • q: quit": "Enter: 执行 • q: 退出", + "Enlarge the terminal before confirming.": "确认前请扩大终端。", + "Codex may start this message while you review it. Esc: cancel.": "查看期间Codex可能开始处理此消息。Esc: 取消", + "Send this saved prompt now? This starts Codex work.": "立即发送此消息?Codex将开始工作。", + " // Due in ": " // 剩余 ", + " // Time reached": " // 已到时间", + " // waiting for fresh quota": " // 等待最新配额", + " // waiting for the original account": " // 等待原账户", + " // waiting for observed quota windows": " // 等待已观测配额窗口", + " // waiting for quota": " // 等待配额", + " // waiting for session to finish": " // 等待会话结束", + " // waiting for an eligible idle session": " // 等待符合条件的空闲会话", + " // ready; awaiting dispatch": " // 已就绪; 等待发送", + " // open follow-ups": " // 打开后续消息", + " // %d–%d // scroll": " // %d–%d // 滚动", + " // ↑↓ · Enter: edit · X: delete · Esc": " // ↑↓ · Enter: 编辑 · X: 删除 · Esc", + "Status: ": "状态: ", + "Unnamed session": "未命名会话", + "SCHEDULE FOLLOW-UP // ": "计划 // 后续消息 // ", + "EDIT SCHEDULED FOLLOW-UP // ": "编辑 // 已计划 // 后续消息 // ", + "AFTER QUOTA REFRESH": "配额恢复后", + "IN…": "延时…", + "AT…": "定时…", + "AT ": "定时 ", + "Hours: %d": "小时: %d", + "Minutes: %02d": "分钟: %02d", + "Hour: ": "时: ", + "Minute: ": "分: ", + "Su Mo Tu We Th Fr Sa": "日 一 二 三 四 五 六", + "[ BACK ]": "[ 返回 ]", + "[ SAVE CHANGES ]": "[ 保存更改 ]", + "[ CONFIRM SCHEDULE ]": "[ 确认计划 ]", + "[ Ctrl+S: SCHEDULE ]": "[ Ctrl+S: 计划 ]", + "[ Ctrl+S: CLOSE SCHEDULE ]": "[ Ctrl+S: 关闭计划 ]", + "QUEUED": "已排队", + "ACCEPTED // CHECKING QUEUE": "已接受 // 检查队列", + "SCHEDULED · ": "已计划 · ", + "[NOW]": "[现在]", + "[EDIT]": "[编辑]", + "FOLLOW-UPS // %d": "后续消息 // %d", + "FOLLOW-UPS // UNAVAILABLE": "后续消息 // 不可用", + "EDIT QUEUED MESSAGE": "编辑 // 已排队", + "DELETE QUEUED MESSAGE": "删除 // 已排队", + "[ CONFIRM DELETE ]": "[ 删除? ]", + "[ WAIT… ]": "[ 等待… ]", + "[ CANCEL ]": "[ 取消 ]", + "After quota recovery": "配额恢复后", + "Not before ": "发送时间: ", + "[ Ctrl+N: SEND NOW ]": "[ Ctrl+N: 立即发送 ]", + "[ Ctrl+S: EDIT ]": "[ Ctrl+S: 编辑 ]", + "[ Ctrl+D: DELETE ]": "[ Ctrl+D: 删除 ]", + "[ Ctrl+Y: CONFIRM SEND ]": "[ Ctrl+Y: 立即发送? ]", + "[ Esc: CANCEL ]": "[ Esc: 取消 ]", + "pending": "待处理", + "sending": "发送中", + "uncertain": "不确定", + "sent": "已发送" } diff --git a/internal/schedule/queue.go b/internal/schedule/queue.go new file mode 100644 index 0000000..462bbc4 --- /dev/null +++ b/internal/schedule/queue.go @@ -0,0 +1,167 @@ +// Package schedule owns in-memory one-off follow-ups independently of the UI. +// Closing Codexometer cancels pending work. Uncertain dispatch is never retried. +package schedule + +import ( + "context" + "crypto/rand" + "errors" + "slices" + "strings" + "sync" + "time" + + "github.com/merefield/codexometer/internal/codex" +) + +// ErrDeferred is only for local preflight checks that guarantee no send was +// attempted. Transport errors must never use it: their outcome is uncertain. +var ErrDeferred = errors.New("trigger deferred before sending") + +type Job struct { + ID string `json:"id"` + Session string `json:"session"` + Text string `json:"text"` + Trigger string `json:"trigger"` // at or quota + At time.Time `json:"at,omitempty"` + Zone string `json:"zone"` + Status string `json:"status"` // pending, sending, sent, uncertain + Windows []string `json:"-"` // Observed windows must not silently disappear. + account string +} + +type Queue struct { + mu sync.Mutex + jobs []Job +} + +func New() *Queue { return &Queue{} } +func (j Job) MatchesAccount(account string) bool { return account != "" && j.account == account } +func (q *Queue) List(session string) []Job { + q.mu.Lock() + defer q.mu.Unlock() + out := []Job{} + for _, j := range q.jobs { + if session == "" || j.Session == session { + j.Windows = append([]string(nil), j.Windows...) + out = append(out, j) + } + } + return out +} + +func (q *Queue) Save(j Job, account string, now time.Time) error { + return q.save(j, account, now, nil) +} + +// SaveIfCurrent binds an edit to the exact pending job. An empty expected ID +// creates only if there is no current job. Check and replacement share the lock. +func (q *Queue) SaveIfCurrent(j Job, account string, now time.Time, expectedID string) error { + return q.save(j, account, now, &expectedID) +} + +func (q *Queue) save(j Job, account string, now time.Time, expectedID *string) error { + j.Text = strings.TrimSpace(j.Text) + if account == "" || j.Session == "" || len(j.Session) > 512 || j.Text == "" || len([]rune(j.Text)) > 4096 || len(j.Zone) > 128 || codex.SanitizeSessionContext(j.Text) != j.Text || (j.Trigger == "quota" && !j.At.IsZero()) || + (j.Trigger != "at" && j.Trigger != "quota") || (j.Trigger == "at" && (!j.At.After(now) || j.At.After(now.AddDate(1, 0, 0)))) { + return errors.New("choose a live session, a message and a future time within one year, or quota recovery") + } + q.mu.Lock() + defer q.mu.Unlock() + index := -1 + q.jobs = slices.DeleteFunc(q.jobs, func(v Job) bool { return v.Status == "sent" }) + for i, v := range q.jobs { + if v.Session == j.Session { + if v.Status == "sending" || v.Status == "uncertain" { + return errors.New("check the previous send in Codex before cancelling or replacing it") + } + index = i + break + } + } + if expectedID != nil { + if index < 0 && *expectedID != "" || index >= 0 && (q.jobs[index].ID != *expectedID || !q.jobs[index].MatchesAccount(account)) { + return errors.New("trigger changed, removed or already sent; reopen it before editing") + } + } + j.Status = "pending" + j.ID = rand.Text() // An edit invalidates confirmations for the old payload. + j.Windows = append([]string(nil), j.Windows...) + j.account = account + j.At = j.At.UTC() + if index >= 0 { + q.jobs[index] = j + } else { + if len(q.jobs) >= 128 { + return errors.New("schedule limit reached; remove old entries first") + } + q.jobs = append(q.jobs, j) + } + return nil +} + +func (q *Queue) Cancel(session, id string) error { + q.mu.Lock() + defer q.mu.Unlock() + for i, j := range q.jobs { + if j.Session != session || j.ID != id { + continue + } + if j.Status == "sending" { + return errors.New("send in progress; check Codex") + } + q.jobs = append(q.jobs[:i], q.jobs[i+1:]...) + return nil + } + return errors.New("schedule changed; refresh") +} + +// Dispatch is called only after the UI adapter has checked freshness, session +// readiness and pending reviews. Claim sending before network IO. This is +// intentionally at-most-one attempt, not a claim of exactly-once delivery. +func (q *Queue) Dispatch(ctx context.Context, id, account string, quotaReady bool, now time.Time, send func(context.Context, Job) error) error { + return q.dispatch(ctx, id, account, quotaReady, now, false, send) +} + +// DispatchNow shares the automatic dispatch claim; only the time rule is bypassed. +func (q *Queue) DispatchNow(ctx context.Context, id, account string, now time.Time, send func(context.Context, Job) error) error { + return q.dispatch(ctx, id, account, true, now, true, send) +} + +func (q *Queue) dispatch(ctx context.Context, id, account string, quotaReady bool, now time.Time, immediate bool, send func(context.Context, Job) error) error { + q.mu.Lock() + index := -1 + for i, j := range q.jobs { + if j.ID == id && j.Status == "pending" && account != "" && j.account == account && + (immediate || (j.Trigger == "at" && !now.Before(j.At)) || (j.Trigger == "quota" && quotaReady)) { + index = i + break + } + } + if index < 0 { + q.mu.Unlock() + if immediate { + return errors.New("trigger changed or already dispatched; refresh") + } + return nil + } + j := q.jobs[index] + q.jobs[index].Status = "sending" + q.mu.Unlock() + err := send(ctx, j) + q.mu.Lock() + defer q.mu.Unlock() + for i, v := range q.jobs { + if v.ID == id { + index = i + break + } + } + q.jobs[index].Status = "sent" + if errors.Is(err, ErrDeferred) { + q.jobs[index].Status = "pending" + } else if err != nil { + q.jobs[index].Status = "uncertain" + } + return err +} diff --git a/internal/schedule/queue_test.go b/internal/schedule/queue_test.go new file mode 100644 index 0000000..2ef361f --- /dev/null +++ b/internal/schedule/queue_test.go @@ -0,0 +1,270 @@ +package schedule + +import ( + "context" + "errors" + "fmt" + "github.com/merefield/codexometer/internal/codex" + "sync" + "sync/atomic" + "testing" + "time" +) + +func TestPreflightDeferralDoesNotBecomeUncertain(t *testing.T) { + q := New() + now := time.Now() + if err := q.Save(Job{Session: "root", Text: "later", Trigger: "quota"}, "a", now); err != nil { + t.Fatal(err) + } + id := q.List("")[0].ID + err := q.Dispatch(context.Background(), id, "a", true, now, func(context.Context, Job) error { return fmt.Errorf("quota changed: %w", ErrDeferred) }) + if !errors.Is(err, ErrDeferred) || q.List("")[0].Status != "pending" { + t.Fatal("preflight failure blocked the job", err, q.List("")) + } + calls := 0 + if err := q.Dispatch(context.Background(), id, "a", true, now, func(context.Context, Job) error { calls++; return nil }); err != nil || calls != 1 { + t.Fatal("job did not recover", err) + } +} + +func TestEditsRequireCurrentPendingJob(t *testing.T) { + for _, change := range []string{"replace", "cancel", "send", "account"} { + t.Run(change, func(t *testing.T) { + q, now := New(), time.Now() + job := Job{Session: "one", Text: "original", Trigger: "quota"} + if err := q.SaveIfCurrent(job, "a", now, ""); err != nil { + t.Fatal(err) + } + id := q.List("one")[0].ID + account := "a" + switch change { + case "replace": + job.Text = "newer edit" + if err := q.SaveIfCurrent(job, "a", now, id); err != nil { + t.Fatal(err) + } + case "cancel": + if err := q.Cancel("one", id); err != nil { + t.Fatal(err) + } + case "send": + if err := q.Dispatch(context.Background(), id, "a", true, now, func(context.Context, Job) error { return nil }); err != nil { + t.Fatal(err) + } + case "account": + account = "b" + } + job.Text = "stale edit" + if err := q.SaveIfCurrent(job, account, now, id); err == nil { + t.Fatal("stale edit accepted") + } + for _, j := range q.List("one") { + if j.Text == "stale edit" { + t.Fatal("stale edit changed queue") + } + } + }) + } +} + +func TestConcurrentEditsHaveOneWinner(t *testing.T) { + q, now := New(), time.Now() + j := Job{Session: "one", Text: "original", Trigger: "quota"} + if err := q.SaveIfCurrent(j, "a", now, ""); err != nil { + t.Fatal(err) + } + id := q.List("one")[0].ID + if err := q.SaveIfCurrent(j, "a", now, ""); err == nil { + t.Fatal("create overwrote existing job") + } + var wg sync.WaitGroup + var wins atomic.Int32 + for range 2 { + wg.Go(func() { + if q.SaveIfCurrent(j, "a", now, id) == nil { + wins.Add(1) + } + }) + } + wg.Wait() + if wins.Load() != 1 { + t.Fatalf("%d edits won", wins.Load()) + } +} + +func TestCompletedJobsDoNotConsumeQueueCapacity(t *testing.T) { + q := New() + now := time.Now() + for i := range 300 { + if err := q.Save(Job{Session: fmt.Sprint(i), Text: "later", Trigger: "quota"}, "a", now); err != nil { + t.Fatal(i, err) + } + jobs := q.List("") + if len(jobs) != 1 { + t.Fatal("completed jobs retained", len(jobs)) + } + if err := q.Dispatch(context.Background(), jobs[0].ID, "a", true, now, func(context.Context, Job) error { return nil }); err != nil { + t.Fatal(err) + } + } +} + +func TestQueueGuardsAndSingleAttempt(t *testing.T) { + now := time.Now() + q := New() + j := Job{Session: "root", Text: "hello", Trigger: "at", At: now.Add(time.Minute)} + if err := q.Save(j, "account", now); err != nil { + t.Fatal(err) + } + id := q.List("root")[0].ID + var calls atomic.Int32 + send := func(context.Context, Job) error { calls.Add(1); return errors.New("lost connection") } + for _, tc := range []struct { + account string + at time.Time + }{{"other", now.Add(time.Hour)}, {"account", now}} { + _ = q.Dispatch(context.Background(), id, tc.account, true, tc.at, send) + } + if calls.Load() != 0 { + t.Fatal("premature dispatch") + } + var wg sync.WaitGroup + for range 20 { + wg.Go(func() { _ = q.Dispatch(context.Background(), id, "account", true, now.Add(time.Hour), send) }) + } + wg.Wait() + if calls.Load() != 1 || q.List("root")[0].Status != "uncertain" { + t.Fatal("must attempt once and retain uncertain outcome") + } + if q.Save(j, "account", now) == nil { + t.Fatal("replaced uncertain send") + } + if q.Cancel("other", id) == nil { + t.Fatal("cancelled wrong session") + } + if err := q.Cancel("root", id); err != nil { + t.Fatal(err) + } + if len(q.List("")) != 0 { + t.Fatal("not cancelled") + } +} + +func TestQueueReplaceQuotaCancelAndMemory(t *testing.T) { + now := time.Now() + q := New() + j := Job{Session: "root", Text: "one", Trigger: "quota"} + if err := q.Save(j, "account", now); err != nil { + t.Fatal(err) + } + id := q.List("")[0].ID + j.Text = "two" + if err := q.Save(j, "account", now); err != nil { + t.Fatal(err) + } + if len(q.List("")) != 1 || q.List("")[0].ID == id { + t.Fatal("replacement must invalidate the old ID without duplicating the job") + } + id = q.List("")[0].ID + calls := 0 + send := func(_ context.Context, j Job) error { + calls++ + if j.Text != "two" { + t.Fatal(j.Text) + } + return nil + } + _ = q.Dispatch(context.Background(), id, "account", false, now, send) + if calls != 0 { + t.Fatal("quota not ready") + } + _ = q.Dispatch(context.Background(), id, "account", true, now, send) + _ = q.Dispatch(context.Background(), id, "account", true, now, send) + if calls != 1 { + t.Fatal(calls) + } + if len(New().List("")) != 0 { + t.Fatal("persisted queue") + } + if err := q.Save(j, "account", now); err != nil { + t.Fatal(err) + } + if len(q.List("")) != 1 { + t.Fatal("sent records accumulated") + } +} + +func TestQuotaReadiness(t *testing.T) { + now := time.Now() + s := codex.Snapshot{AccountFingerprint: "a", FetchedAt: now, RateLimits: codex.RateLimitSnapshot{Primary: &codex.Window{UsedPercent: 10}}} + if !QuotaReady(s, now) { + t.Fatal("ready quota rejected") + } + s.RateLimits.Primary.UsedPercent = 100 + if QuotaReady(s, now) { + t.Fatal("exhausted quota accepted") + } + s.RateLimits.Primary.UsedPercent = 0 + if QuotaReady(s, now.Add(91*time.Second)) || QuotaReady(s, now.Add(-time.Second)) { + t.Fatal("stale/future snapshot") + } + flag := true + s.RateLimits.SpendControlReached = &flag + if QuotaReady(s, now) { + t.Fatal("spend control ignored") + } + s.RateLimits.SpendControlReached = nil + s.RateLimits.Primary = nil + if QuotaReady(s, now) { + t.Fatal("missing windows accepted") + } +} + +func TestInvalidJobs(t *testing.T) { + now := time.Now() + for _, j := range []Job{{Session: "s", Text: "hi", Trigger: "at", At: now.Add(-time.Minute)}, {Session: "s", Text: "\x1b[31mhello", Trigger: "quota"}, {Session: "s", Text: "hi", Trigger: "quota", At: now}, {Session: "s", Trigger: "quota"}} { + if New().Save(j, "a", now) == nil { + t.Fatalf("accepted %+v", j) + } + } +} + +func TestMissingWindowIsNotRecovery(t *testing.T) { + s := codex.Snapshot{RateLimits: codex.RateLimitSnapshot{Primary: &codex.Window{UsedPercent: 100}}} + j := Job{Windows: WindowKeys(s)} + if !Covers(j, s) { + t.Fatal("same window rejected") + } + s.RateLimits.Primary = nil + if Covers(j, s) { + t.Fatal("missing exhausted window treated as recovered") + } +} + +func TestManualAndAutomaticDispatchShareClaim(t *testing.T) { + now := time.Now() + q := New() + j := Job{Session: "root", Text: "one", Trigger: "at", At: now.Add(time.Hour)} + if err := q.Save(j, "account", now); err != nil { + t.Fatal(err) + } + id := q.List("")[0].ID + var calls atomic.Int32 + var wg sync.WaitGroup + send := func(context.Context, Job) error { calls.Add(1); return nil } + for range 20 { + wg.Go(func() { _ = q.DispatchNow(context.Background(), id, "account", now, send) }) + wg.Go(func() { _ = q.Dispatch(context.Background(), id, "account", true, now.Add(time.Hour), send) }) + } + wg.Wait() + if calls.Load() != 1 { + t.Fatal("duplicate sends", calls.Load()) + } + if err := q.Save(j, "account", now); err != nil { + t.Fatal(err) + } + if q.DispatchNow(context.Background(), id, "account", now, send) == nil { + t.Fatal("old confirmation accepted") + } +} diff --git a/internal/schedule/quota.go b/internal/schedule/quota.go new file mode 100644 index 0000000..819fd15 --- /dev/null +++ b/internal/schedule/quota.go @@ -0,0 +1,62 @@ +package schedule + +import ( + "fmt" + "github.com/merefield/codexometer/internal/codex" + "slices" + "time" +) + +func WindowKeys(s codex.Snapshot) []string { + var keys []string + for _, m := range s.Meters() { + keys = append(keys, fmt.Sprintf("%s/%d/%s", m.LimitID, m.Kind, m.Name)) + } + return keys +} + +// Missing data is not evidence that an exhausted window recovered. +func Covers(j Job, s codex.Snapshot) bool { + keys := WindowKeys(s) + for _, k := range j.Windows { + if !slices.Contains(keys, k) { + return false + } + } + return true +} + +// Until Codex exposes authoritative model-to-bucket applicability, waiting for +// every reported limit is conservative: never assume an exhausted bucket is +// irrelevant. No inference, reset redemption, or timestamp-only release. +func QuotaReady(s codex.Snapshot, now time.Time) bool { + if !Fresh(s, now) { + return false + } + meters := s.Meters() + if len(meters) == 0 { + return false + } + for _, m := range meters { + if m.Window.UsedPercent < 0 || m.Window.UsedPercent >= 100 { + return false + } + } + buckets := s.RateLimitsByLimitID + if len(buckets) == 0 { + buckets = map[string]codex.RateLimitSnapshot{"codex": s.RateLimits} + } + for _, b := range buckets { + if b.SpendControlReached != nil && *b.SpendControlReached { + return false + } + if b.RateLimitReachedType != nil && *b.RateLimitReachedType != "" { + return false + } + } + return true +} + +func Fresh(s codex.Snapshot, now time.Time) bool { + return s.AccountFingerprint != "" && !s.FetchedAt.IsZero() && !now.Before(s.FetchedAt) && now.Sub(s.FetchedAt) <= 90*time.Second +} diff --git a/internal/ui/localisation_test.go b/internal/ui/localisation_test.go index 68925e9..5581745 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("schedule_click_surfaces", TestScheduleRenderedControlSurfaces) + t.Run("schedule_status_labels", TestScheduleLocalisedStatusAndHint) + t.Run("queue_editor_surfaces", TestQueueEditorButtonsAndKeyboardFocus) t.Run("monitor_copy_surfaces", TestMonitorCopySurfaces) t.Run("monitor_attention_surfaces", TestMonitorAttentionRenderedHitSurfaces) t.Run("monitor_attention_padding", TestMonitorAttentionTallPadding) diff --git a/internal/ui/model.go b/internal/ui/model.go index caa4a4a..02dbcd5 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -44,7 +44,12 @@ type BenchmarkTaskProvider interface { } type Model struct { + // Only populated on a local copy during a read-only render/hit-test pass. + geometry *monitorGeometryCache + scheduleUI scheduleUI + monitorQueue monitorQueueState monitorPrompt monitorPromptState + monitorDrafts map[string]string monitorContextHidden bool monitorContextDetail string monitorContextExpanded string @@ -459,6 +464,20 @@ func (m Model) Init() tea.Cmd { } func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) { + colors := paletteFor(m.theme) + m.monitorPrompt.input.style(colors) + m.monitorQueue.input.style(colors) + m.scheduleUI.input.style(colors) + if next, cmd, handled := m.updateMonitorQueue(message); handled { + return next, cmd + } else { + m = next + } + if next, cmd, handled := m.updateSchedule(message); handled { + return next, cmd + } else { + m = next + } if next, cmd, handled := m.updateMonitorPrompt(message); handled { return next, cmd } else { @@ -1073,6 +1092,8 @@ func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) { } m.phase++ commands := []tea.Cmd{secondTick()} + commands = append(commands, m.pollMonitorQueue()) + commands = append(commands, m.dispatchSchedules()) if m.monitorState == monitorRunning { now := time.Time(message) if !m.monitorNextSample.IsZero() && !now.Before(m.monitorNextSample) { @@ -1253,6 +1274,7 @@ func (m Model) pressViewTab(view meterViewID) (tea.Model, tea.Cmd) { } if view != viewMonitor { m.monitorDetailSent = detailSentState{} + m.stashMonitorDraft() m.monitorPrompt = monitorPromptState{} m.monitorContextDetail = "" m.monitorContextExpanded = "" @@ -2664,6 +2686,22 @@ func (m *Model) startMonitorSessions(usage codex.LiveUsageSnapshot, observedAt t } func (m *Model) syncMonitorSessions(usage codex.LiveUsageSnapshot, observedAt time.Time) { + follow := false + if m.monitorContextDetail != "" { + if s, ok := m.contextDetailSession(); ok { + for _, update := range usage.Sessions { + if update.ID == s.id && update.Context.ThreadID == s.preview.ThreadID && update.Context.TurnID == s.preview.TurnID && (s.preview.Streaming || update.Context.Streaming) { + follow = m.monitorContextScroll >= m.monitorContextScrollLimit() + break + } + } + } + } + defer func() { + if follow { + m.monitorContextScroll = m.monitorContextScrollLimit() + } + }() for index := range m.monitorSessionData { m.monitorSessionData[index].active = false m.monitorSessionData[index].working = false @@ -2945,6 +2983,7 @@ func (m *Model) selectMonitorSession(direction int) { m.monitorContextExpanded = "" m.monitorApprovalConfirm = "" m.monitorApprovalNotice = "" + m.stashMonitorDraft() m.monitorPrompt = monitorPromptState{} } pageSize := max(m.monitorPageSize(), 1) diff --git a/internal/ui/monitor.go b/internal/ui/monitor.go index 513f810..d7b3ab1 100644 --- a/internal/ui/monitor.go +++ b/internal/ui/monitor.go @@ -410,7 +410,10 @@ func monitorSessionDismissRect(metricsWidth, rowY int) (monitorRect, bool) { func (m Model) renderMonitorSessionBadge(session monitorSession, width int, colors palette) string { badgeLabel := "" - if session.attention != codex.SessionAttentionNone { + trigger := m.sessionTriggerStatus(session) + if trigger { + badgeLabel = i18n.Text("TRIGGER SET") + } else if session.attention != codex.SessionAttentionNone { badgeLabel = monitorSessionAttentionLabel(session) } else if m.sessionObservedWorking(session) { badgeLabel = i18n.Text("WORKING") @@ -419,18 +422,18 @@ func (m Model) renderMonitorSessionBadge(session monitorSession, width int, colo badgeColor := colors.primary if monitorNeedsAttention(session.attention) { badgeColor = colors.warning - } else if session.attention == codex.SessionAttentionNone { + } else if session.attention == codex.SessionAttentionNone && !trigger { badgeColor = colors.success } else if session.id != "" && (session.id == m.monitorSelectedID || session.id == m.monitorContextExpanded) { // Selection accents the frame, not the meaning of the status badge. badgeColor = paletteFor(m.theme).primary } badge := lipgloss.NewStyle().Bold(true).Foreground(colors.background).Background(badgeColor) - if m.monitorContextHover == "badge:"+session.id { + if m.monitorContextHover == "badge:"+session.id || (m.sessionTriggerStatus(session) && m.monitorContextHover == "attention-trigger:"+session.id) { badge = badge.Underline(true) } ball := "●" - if session.attention == codex.SessionAttentionNone && m.phase%2 == 1 { + if session.attention == codex.SessionAttentionNone && !trigger && m.phase%2 == 1 { ball = " " // Blink only WORKING, reserving its cell to avoid layout movement. } return badge.Render(ansi.Truncate(" "+ball+" "+badgeLabel+" ", width, "")) @@ -622,6 +625,18 @@ func shortSessionID(id string) string { return strings.ToUpper(string(runes[len(runes)-5:])) } +func monitorSessionIdentity(s monitorSession) string { + identity := shortSessionID(s.id) + name := terminalLabel(s.name) + if name == "" && s.workingDirectory != "" { + name = filepath.Base(terminalLabel(s.workingDirectory)) + } + if name != "" { + identity += " // " + name + } + return identity +} + func plural(count int, singular, plural string) string { if count == 1 { return singular diff --git a/internal/ui/monitor_context.go b/internal/ui/monitor_context.go index a2d5350..d601ccf 100644 --- a/internal/ui/monitor_context.go +++ b/internal/ui/monitor_context.go @@ -18,10 +18,19 @@ func monitorSessionAttentionLabel(s monitorSession) string { func contextTitle(c codex.SessionContext) string { if c.Text == "" { + if c.CurrentTask != "" { + return i18n.Text("CURRENT TASK") + } + if c.LatestGuidance != "" { + return i18n.Text("LATEST GUIDANCE") + } return i18n.Text("NO CONTEXT") } switch c.Kind { case codex.SessionContextReply: + if c.Streaming { + return i18n.Text("REPLY") + " // " + i18n.Text("STREAMING") + } return i18n.Text("LAST REPLY") case codex.SessionContextQuestion: return i18n.Text("QUESTION") @@ -32,6 +41,13 @@ func contextTitle(c codex.SessionContext) string { } } +func contextTaskTitle(c codex.SessionContext) string { + if c.Kind == codex.SessionContextReply && !c.Streaming { + return i18n.Text("TASK") + } + return i18n.Text("CURRENT TASK") +} + func (m Model) monitorPrivacyLabel(widths ...int) string { g := m.monitorDashboardLayout() width := m.monitorArea(g.contentWidth, g.meterHeight).readoutWidth @@ -107,6 +123,12 @@ func (m Model) renderMonitorContextRow(width, height int, metrics string, s moni } inner := max(cw-4, 1) text := codex.SanitizeSessionContext(s.preview.Text) + if text == "" { + text = codex.SanitizeSessionContext(s.preview.CurrentTask) + } + if text == "" { + text = codex.SanitizeSessionContext(s.preview.LatestGuidance) + } if text == "" { text = i18n.Text("NO CONTEXT") } @@ -162,6 +184,12 @@ func (m Model) contextDetailSession() (monitorSession, bool) { } func (m Model) renderMonitorContextDetail(width, height int, colors palette) string { + if m.monitorQueue.open { + return m.renderQueueEditor() + } + if m.scheduleUI.open { + return m.renderScheduleDetail(width, height, colors) + } document := m.contextDetailDocument(max(width-4, 1)) lines := make([]string, len(document)) for i, line := range document { @@ -182,16 +210,29 @@ func (m Model) renderMonitorContextDetail(width, height int, colors palette) str } body := strings.Join(bodyLines, "\n") action := m.renderMonitorNavigation(width, m.monitorContextDetail, true, colors) - title := i18n.Text("SESSION CONTEXT") + title := m.monitorDetailTitle(width, colors) + return frameSizedWithActions(width, rows, title, action, m.renderMonitorCopy(width, m.monitorContextDetail, colors), body, colors.primary, colors) +} + +func (m Model) monitorDetailTitle(width int, colors palette) string { + title := i18n.Text("UNKNOWN") if s, ok := m.contextDetailSession(); ok { + title = i18n.Text("IDLE") + if s.active { + title = i18n.Text("ACTIVE") + } if badge := m.renderMonitorSessionBadge(s, max(width-4, 1), colors); badge != "" { title = badge } + if m.monitorState != monitorRunning || m.monitorError != "" { + title = i18n.Text("STALE") + } if m.hasSessionProfile(s) { title = i18n.Text("QUOTA THRESHOLD") } + title += " // " + monitorSessionIdentity(s) } - return frameSizedWithActions(width, rows, title, action, m.renderMonitorCopy(width, m.monitorContextDetail, colors), body, colors.primary, colors) + return title } // Reserve the footer before allocating the scroll viewport. Short terminals @@ -209,6 +250,7 @@ func monitorContextBodyLayout(height, controls int) (textRows, gap, controlY int func (m *Model) toggleMonitorContext() { m.clearQuotaConfirmation() + m.stashMonitorDraft() m.monitorPrompt = monitorPromptState{} m.monitorApprovalConfirm = "" m.monitorApprovalNotice = "" @@ -223,7 +265,7 @@ func (m *Model) toggleMonitorContext() { func (m *Model) openMonitorContext(id string) { for _, s := range m.monitorSessionData { - if s.id == id && (s.preview.Text != "" || s.preview.Kind == codex.SessionContextApproval || m.hasSessionProfile(s) || id == m.monitorContextExpanded) && m.monitorSessionVisible(s) { + if s.id == id && (s.preview.Text != "" || s.preview.CurrentTask != "" || s.preview.LatestGuidance != "" || s.preview.Kind == codex.SessionContextApproval || m.hasSessionProfile(s) || id == m.monitorContextExpanded) && m.monitorSessionVisible(s) { m.setRowContext(id, contextFull) return } @@ -231,11 +273,15 @@ func (m *Model) openMonitorContext(id string) { } func (m *Model) scrollMonitorContext(delta int) { + m.monitorContextScroll = min(max(m.monitorContextScroll+delta, 0), m.monitorContextScrollLimit()) +} + +func (m Model) monitorContextScrollLimit() int { g := m.monitorDashboardLayout() layout := m.layoutDetailControls(g.contentWidth, g.meterHeight) rows, _, _ := monitorContextBodyLayout(g.meterHeight, layout.rows) limit := max(len(m.contextDetailLines(max(g.contentWidth-4, 1)))-rows, 0) - m.monitorContextScroll = min(max(m.monitorContextScroll+delta, 0), limit) + return limit } func (m Model) updateMonitorContextKey(key string) (Model, tea.Cmd, bool) { @@ -317,12 +363,22 @@ func (m Model) updateMonitorContextKey(key string) (Model, tea.Cmd, bool) { } func (m Model) monitorContextAt(x, y int) string { + m.geometry = &monitorGeometryCache{} if m.meterView != viewMonitor || m.loading && len(m.snapshot.Meters()) == 0 { return "" } if hit := m.monitorAttentionAt(x, y); hit != "" { return hit } + if hit := m.monitorQueueAt(x, y); hit != "" { + return hit + } + if b, hit := m.scheduleButtonAt(x, y); hit { + if b.enabled { + return "schedule:" + b.key + } + return "schedule:disabled" + } g := m.monitorDashboardLayout() x -= 2 y -= g.meterY @@ -383,6 +439,9 @@ func (m Model) monitorContextAt(x, y int) string { for i, s := range sessions { badge := m.renderMonitorSessionBadge(s, max(mw-4, 1), paletteFor(m.theme)) if badge != "" && y == rowY+1 && x >= 2 && x < 2+lipgloss.Width(badge) && x < mw-2 { + if m.sessionTriggerStatus(s) { + return "attention-trigger:" + s.id + } return "badge:" + s.id } boxX, boxWidth := mw+1, rightWidth diff --git a/internal/ui/monitor_context_modes.go b/internal/ui/monitor_context_modes.go index 38c74e0..48ed92a 100644 --- a/internal/ui/monitor_context_modes.go +++ b/internal/ui/monitor_context_modes.go @@ -28,7 +28,7 @@ func (m Model) initialMonitorContextTarget() string { if s.id == m.monitorSelectedID { return s.id } - if s.preview.Text == "" { + if s.preview.Text == "" && s.preview.CurrentTask == "" && s.preview.LatestGuidance == "" { continue } if first == "" { @@ -121,17 +121,35 @@ func (m *Model) setRowContext(id string, mode int) { if mode == contextWide { m.monitorContextExpanded = id } - m.monitorPrompt = monitorPromptState{} + if m.monitorPrompt.session != id { + m.stashMonitorDraft() + m.monitorPrompt = monitorPromptState{} + } else { + // Changing presentation is not changing the prompt's owner. Keep the + // draft/capability, but release keyboard focus for navigation. + m.monitorPrompt.input.Blur() + } m.monitorApprovalConfirm, m.monitorApprovalNotice = "", "" m.monitorContextScroll = 0 + m.restoreMonitorDraft() } func expandedContextLines(width int, s monitorSession) []string { - if s.preview.Text == "" { + if s.preview.Text == "" && s.preview.CurrentTask == "" && s.preview.LatestGuidance == "" { return []string{i18n.Text("NO CONTEXT")} } header := terminalLabel(s.preview.ThreadID) + " // " + terminalLabel(s.preview.Source) text := header + "\n" + codex.SanitizeSessionContext(s.preview.Text) + if s.preview.CurrentTask != "" || s.preview.LatestGuidance != "" { + text = header + if s.preview.CurrentTask != "" { + text += "\n" + ansi.Truncate(contextTaskTitle(s.preview)+" // "+strings.Join(strings.Fields(codex.SanitizeSessionContext(s.preview.CurrentTask)), " "), max(width-4, 1), "…") + } + if s.preview.LatestGuidance != "" { + text += "\n" + ansi.Truncate(i18n.Text("LATEST GUIDANCE")+" // "+strings.Join(strings.Fields(codex.SanitizeSessionContext(s.preview.LatestGuidance)), " "), max(width-4, 1), "…") + } + text += "\n\n" + codex.SanitizeSessionContext(s.preview.Text) + } return strings.Split(ansi.Hardwrap(text, max(width-4, 1), true), "\n") } @@ -181,7 +199,24 @@ func (m Model) renderExpandedContext(width, height int, s monitorSession, colors n = 1 controls = colors.label().Render(dots) } + if s.id == m.monitorContextTarget() { + if queueRows := m.monitorQueueRows(width, height, n); queueRows > 0 { + queue := m.renderMonitorQueue(width, queueRows, colors) + if controls != "" { + controls = queue + "\n" + controls + } else { + controls = queue + } + n += queueRows + } + } textRows, gap, _ := monitorContextBodyLayout(height, n) + if s.preview.Kind == codex.SessionContextReply && !s.preview.Streaming && len(lines) > textRows { + // Keep the reply readable in short rows. Full detail retains the task. + withoutTask := s + withoutTask.preview.CurrentTask, withoutTask.preview.LatestGuidance = "", "" + lines = expandedContextLines(width, withoutTask) + } if hasWorkingCommand(s.preview) { lines = workingContextLines(s.preview, max(width-4, 1), max(textRows, 1), false) } @@ -204,6 +239,25 @@ func (m Model) renderExpandedContext(width, height int, s monitorSession, colors return frameSizedWithActions(width, max(height-2, 1), contextTitle(s.preview), action, m.renderMonitorCopy(width, s.id, colors), strings.Join(lines, "\n"), colors.primary, colors) } +// Keep the queue's geometry aligned with the controls already rendered in a row. +func (m Model) expandedControlRows(width, height int, s monitorSession) int { + if buttons := m.expandedApprovalButtons(width, height, s); len(buttons) > 0 { + return buttons[len(buttons)-1].y + 1 + } + if s.id == m.monitorContextTarget() { + if m.monitorApprovalHasOutcome() { + return 1 + } + if rows := m.monitorPromptRows(width, height); rows > 0 { + return rows + } + } + if m.sessionActivityDots(s) != "" && height >= 5 && width >= 7 { + return 1 + } + return 0 +} + // Keep an explicit route to the complete request when inline decisions cannot // be safely offered. The warning never grants approval; it only opens detail. func (m Model) expandedContextNavigation(width, height int, s monitorSession) []monitorNavigationButton { diff --git a/internal/ui/monitor_context_modes_test.go b/internal/ui/monitor_context_modes_test.go index e526073..3323cef 100644 --- a/internal/ui/monitor_context_modes_test.go +++ b/internal/ui/monitor_context_modes_test.go @@ -109,7 +109,13 @@ func TestMonitorStatusAndContentHeadings(t *testing.T) { full := strings.Split(ansi.Strip(m.renderMonitorContextDetail(120, 20, paletteFor(m.theme))), "\n") badge := ansi.Strip(m.renderMonitorSessionBadge(s, 100, paletteFor(m.theme))) if badge == "" { - badge = i18n.Text("SESSION CONTEXT") + badge = i18n.Text("IDLE") + if s.active { + badge = i18n.Text("ACTIVE") + } + } + if tc.state != monitorRunning || tc.err != "" { + badge = i18n.Text("STALE") } if !strings.Contains(full[0], badge) { t.Fatalf("full title lost telemetry badge %q: %q", badge, full[0]) diff --git a/internal/ui/monitor_detail.go b/internal/ui/monitor_detail.go index 8f2fcda..5499bc4 100644 --- a/internal/ui/monitor_detail.go +++ b/internal/ui/monitor_detail.go @@ -31,7 +31,7 @@ func (line detailLine) render(colors palette) string { // 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 { +func (m Model) contextDetailDocument(width int) (document []detailLine) { width = max(width, 1) s, ok := m.contextDetailSession() if !ok { @@ -40,7 +40,7 @@ func (m Model) contextDetailDocument(width int) []detailLine { if doc := m.profileDocument(s, width); doc != nil { return doc } - if s.preview.Text == "" { + if s.preview.Text == "" && s.preview.CurrentTask == "" && s.preview.LatestGuidance == "" { identity := terminalLabel(s.id) + " // " + terminalLabel(s.workingDirectory) var lines []detailLine for _, line := range strings.Split(ansi.Hardwrap(identity, width, true), "\n") { @@ -75,6 +75,14 @@ func (m Model) contextDetailDocument(width int) []detailLine { lines = append(lines, detailLine{title, "heading"}) } appendText(terminalLabel(c.Source)+" // "+terminalLabel(c.ThreadID)+" // "+contextAge(c), "metadata", "") + if c.CurrentTask != "" { + section(contextTaskTitle(c)) + appendText(codex.SanitizeSessionContext(c.CurrentTask), "body", "") + } + if c.LatestGuidance != "" { + section(i18n.Text("LATEST GUIDANCE")) + appendText(codex.SanitizeSessionContext(c.LatestGuidance), "body", "") + } if c.Kind == codex.SessionContextApproval && c.ApprovalContext != "" { section(i18n.Text("CONTEXT")) appendText(codex.SanitizeSessionContext(c.ApprovalContext), "body", "") @@ -116,7 +124,7 @@ func (m Model) contextDetailDocument(width int) []detailLine { appendText(codex.SanitizeSessionContext(option.Detail), "warning", "") } } - } else { + } else if c.Text != "" { section(contextTitle(c)) text := codex.SanitizeSessionContext(c.Text) // Legacy/unstructured requests remain verbatim. This cosmetic spacer diff --git a/internal/ui/monitor_detail_activity.go b/internal/ui/monitor_detail_activity.go index a08757d..d59beea 100644 --- a/internal/ui/monitor_detail_activity.go +++ b/internal/ui/monitor_detail_activity.go @@ -92,13 +92,21 @@ func (m Model) sessionObservedWorking(s monitorSession) bool { } type detailControlLayout struct { - kind string - rows int - notice string + kind string + rows int + notice string + queueRows int } // Scroll geometry needs only layout, not a styled rendering of the controls. func (m Model) layoutDetailControls(width, height int) detailControlLayout { + layout := m.layoutDetailControlsBase(width, height) + layout.queueRows = m.monitorQueueRows(width, height, layout.rows) + layout.rows += layout.queueRows + return layout +} + +func (m Model) layoutDetailControlsBase(width, height int) detailControlLayout { if s, ok := m.contextDetailSession(); ok { if buttons := m.profileButtons(width, height, s); len(buttons) > 0 { return detailControlLayout{kind: "profile", rows: buttons[len(buttons)-1].y + 1} @@ -107,6 +115,12 @@ func (m Model) layoutDetailControls(width, height int) detailControlLayout { if rows := m.monitorApprovalControlRows(width, height); rows > 0 { return detailControlLayout{kind: "approval", rows: rows} } + if m.monitorContextDetail != "" && m.hasSchedule(m.monitorContextDetail) && len(m.monitorPromptOffer().Questions) == 0 && (m.scheduleUI.confirmID != "" || m.scheduleUI.notice != "") { + if width < 24 || height < 8 { + return detailControlLayout{kind: "notice", rows: 1, notice: i18n.Text("Ctrl+S: EDIT")} + } + return detailControlLayout{kind: "schedule", rows: len(m.schedulePanelLines(width))} + } if rows := m.monitorPromptRows(width, height); rows > 0 { return detailControlLayout{kind: "prompt", rows: rows} } @@ -121,7 +135,21 @@ func (m Model) layoutDetailControls(width, height int) detailControlLayout { } func (layout detailControlLayout) render(m Model, width, height int, colors palette) string { + body := layout.renderBase(m, width, height, colors) + if layout.queueRows > 0 { + queue := m.renderMonitorQueue(width, layout.queueRows, colors) + if body != "" { + return queue + "\n" + body + } + return queue + } + return body +} + +func (layout detailControlLayout) renderBase(m Model, width, height int, colors palette) string { switch layout.kind { + case "schedule": + return strings.Join(m.schedulePanelLines(width), "\n") case "profile": s, _ := m.contextDetailSession() return m.renderProfileControls(m.profileButtons(width, height, s), colors) diff --git a/internal/ui/monitor_editor.go b/internal/ui/monitor_editor.go index c761d5d..3de229d 100644 --- a/internal/ui/monitor_editor.go +++ b/internal/ui/monitor_editor.go @@ -4,15 +4,18 @@ import ( "charm.land/bubbles/v2/textarea" "charm.land/bubbles/v2/textinput" tea "charm.land/bubbletea/v2" + "image/color" ) // 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 + area *textarea.Model + password *textinput.Model + secret bool + ready bool + width, height int + styleName string } func newMonitorEditor() monitorEditor { @@ -29,13 +32,34 @@ func newMonitorEditor() monitorEditor { p := textinput.New() p.CharLimit = 4096 p.EchoMode = textinput.EchoPassword - return monitorEditor{area: a, password: p, ready: true} + return monitorEditor{area: &a, password: &p, ready: true} +} + +// Keep inactive editors cheap to carry through Bubble Tea's value models. +// Detach the widget before mutation, preserving the previous value-copy +// semantics without copying both large widgets on every layout query. +func (e *monitorEditor) editArea() *textarea.Model { + a := *e.area + e.area = &a + return e.area +} + +func (e *monitorEditor) editPassword() *textinput.Model { + p := *e.password + e.password = &p + return e.password } func (e *monitorEditor) configure(width, height int) { if !e.ready { return } + if e.width == width && e.height == height { + return + } + e.width, e.height = width, height + e.editArea() + e.editPassword() e.area.MaxHeight = max(height-8, 1) e.area.SetWidth(max(width-4, 1)) e.password.SetWidth(max(width-6, 1)) @@ -63,17 +87,17 @@ func (e monitorEditor) Value() string { } func (e *monitorEditor) SetValue(s string) { if e.secret { - e.password.SetValue(s) + e.editPassword().SetValue(s) } else { - e.area.SetValue(s) + e.editArea().SetValue(s) } } func (e *monitorEditor) Reset() { if !e.ready { return } - e.area.Reset() - e.password.Reset() + e.editArea().Reset() + e.editPassword().Reset() } func (e monitorEditor) Focused() bool { if !e.ready { @@ -86,22 +110,22 @@ func (e monitorEditor) Focused() bool { } func (e *monitorEditor) Focus() tea.Cmd { if e.secret { - return e.password.Focus() + return e.editPassword().Focus() } - return e.area.Focus() + return e.editArea().Focus() } func (e *monitorEditor) Blur() { if !e.ready { return } - e.area.Blur() - e.password.Blur() + e.editArea().Blur() + e.editPassword().Blur() } func (e *monitorEditor) CursorEnd() { if e.secret { - e.password.CursorEnd() + e.editPassword().CursorEnd() } else { - e.area.CursorEnd() + e.editArea().CursorEnd() } } func (e monitorEditor) Height() int { @@ -113,26 +137,52 @@ func (e monitorEditor) Height() int { func (e monitorEditor) Update(msg tea.Msg) (monitorEditor, tea.Cmd) { var cmd tea.Cmd if e.secret { - e.password, cmd = e.password.Update(msg) + p, command := e.password.Update(msg) + e.password, cmd = &p, command } else { - e.area, cmd = e.area.Update(msg) + a, command := e.area.Update(msg) + e.area, cmd = &a, command } 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() +func (e *monitorEditor) style(colors palette) { + if !e.ready || e.styleName == colors.name { + return } - s := e.area.Styles() + e.styleName = colors.name + e.editArea() + e.editPassword() + s := e.password.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) + e.password.SetStyles(s) + areaStyles := e.area.Styles() + tint := monitorComposerBackground(colors) + areaStyles.Focused.Base = areaStyles.Focused.Base.Background(tint) + areaStyles.Blurred.Base = areaStyles.Blurred.Base.Background(tint) + areaStyles.Focused.Text = colors.label().Background(tint) + areaStyles.Focused.Prompt = colors.label().Foreground(colors.primary) + areaStyles.Focused.CursorLine = colors.label().Background(tint) + areaStyles.Cursor.Color = colors.primary + e.area.SetStyles(areaStyles) +} + +func (e monitorEditor) View(colors palette) string { + e.style(colors) + if e.secret { + return e.password.View() + } return e.area.View() } + +func monitorComposerBackground(colors palette) color.RGBA { + return monitorTintBackground(colors, 10) +} + +func monitorTintBackground(colors palette, percent uint32) color.RGBA { + percent = min(percent, 100) + pr, pg, pb, _ := colors.primary.RGBA() + br, bg, bb, _ := colors.background.RGBA() + return color.RGBA{uint8((pr*percent + br*(100-percent)) / 100 >> 8), uint8((pg*percent + bg*(100-percent)) / 100 >> 8), uint8((pb*percent + bb*(100-percent)) / 100 >> 8), 255} +} diff --git a/internal/ui/monitor_efficiency_test.go b/internal/ui/monitor_efficiency_test.go new file mode 100644 index 0000000..9c1f21c --- /dev/null +++ b/internal/ui/monitor_efficiency_test.go @@ -0,0 +1,123 @@ +package ui + +import ( + "reflect" + "strings" + "testing" + "time" + + "github.com/charmbracelet/x/ansi" + "github.com/merefield/codexometer/internal/codex" + "github.com/merefield/codexometer/internal/i18n" +) + +func TestMonitorModelFootprint(t *testing.T) { + t.Logf("model=%d bytes editor=%d bytes", reflect.TypeOf(Model{}).Size(), reflect.TypeOf(monitorEditor{}).Size()) + if reflect.TypeOf(monitorEditor{}).Size() > 128 { + t.Fatal("editor embeds heavy widget state in the application value model") + } +} + +func TestMonitorEditorCopiesDetachOnMutation(t *testing.T) { + for _, secret := range []bool{false, true} { + original := newMonitorEditor() + original.setSecret(secret) + original.SetValue("original draft") + original.configure(80, 30) + original.style(paletteFor(themeHacker)) + for name, mutate := range map[string]func(*monitorEditor){ + "text": func(e *monitorEditor) { e.SetValue("replacement") }, + "reset": func(e *monitorEditor) { e.Reset() }, + "focus": func(e *monitorEditor) { e.Focus() }, + "resize": func(e *monitorEditor) { e.configure(40, 20) }, + "theme": func(e *monitorEditor) { e.style(paletteFor(themeRust)) }, + } { + t.Run(name+map[bool]string{true: "_secret", false: "_text"}[secret], func(t *testing.T) { + copy := original + width, height := original.area.Width(), original.area.Height() + mutate(©) + // Bubbles itself shallow-copies internal viewport/cache pointers. + // Preserve its value semantics, without sharing additional state. + if original.Value() != "original draft" || original.Focused() || original.area.Width() != width || original.area.Height() != height || original.styleName != "HACKER" { + t.Fatal("mutating an editor copy altered original widget state") + } + }) + } + } +} + +func TestMonitorGeometryPassMatchesUncached(t *testing.T) { + for _, full := range []bool{false, true} { + m, _ := promptTestModel() + if !full { + m.monitorContextDetail = "" + } + for _, size := range [][2]int{{60, 20}, {120, 40}, {200, 65}} { + m.width, m.height = size[0], size[1] + cached := m + cached.geometry = &monitorGeometryCache{} + for repeat := 0; repeat < 2; repeat++ { + if !reflect.DeepEqual(cached.monitorArea(size[0], size[1]), m.monitorArea(size[0], size[1])) || cached.monitorDashboardLayout() != m.monitorDashboardLayout() { + t.Fatal("cached geometry differs") + } + } + _ = m.render() + _ = m.monitorContextAt(4, 5) + if m.geometry != nil { + t.Fatal("cache escaped the read-only pass") + } + } + } +} + +func TestMonitorEditorConfigurationStableAndResponsive(t *testing.T) { + e := newMonitorEditor() + e.SetValue(strings.Repeat("wrapped text ", 30)) + e.configure(80, 30) + e.style(paletteFor(themeHacker)) + before := e.View(paletteFor(themeHacker)) + e.configure(80, 30) + e.style(paletteFor(themeHacker)) + if e.View(paletteFor(themeHacker)) != before { + t.Fatal("unchanged configuration changed editor") + } + e.configure(40, 30) + if e.width != 40 || e.area.Width() >= 40 { + t.Fatal("resize not applied") + } + e.style(paletteFor(themeRust)) + if e.styleName != "RUST" || e.Value() != strings.Repeat("wrapped text ", 30) { + t.Fatal("theme update lost draft") + } +} + +func TestWorkingComposerHasSingleAnimation(t *testing.T) { + for _, inline := range []bool{false, true} { + m, _ := turnTestModel() + if inline { + m, _ = inlineTurnTestModel() + m.focusMonitorPrompt() + } + m.monitorState = monitorRunning + m.monitorSessionData[0].active = true + m.monitorSessionData[0].working = true + m.monitorSessionData[0].attention = codex.SessionAttentionNone + m.monitorPrompt.notice = i18n.Text("Text sent ...") + m.monitorPrompt.noticeUntil = time.Now().Add(time.Minute) + for phase := 0; phase < 4; phase++ { + m.phase = phase + out := ansi.Strip(m.renderMonitorPrompt(100, 30, paletteFor(m.theme))) + if strings.Count(out, monitorDotWave(phase)) != 1 || !strings.Contains(out, m.monitorPrompt.notice) { + t.Fatalf("expected one wave and plain notice: %s", out) + } + } + m.monitorPrompt.noticeUntil = time.Now().Add(-time.Minute) + if strings.Contains(m.renderMonitorPrompt(100, 30, paletteFor(m.theme)), m.monitorPrompt.notice) { + t.Fatal("notice did not expire") + } + m.monitorState = monitorPaused + if strings.Contains(m.renderMonitorPrompt(100, 30, paletteFor(m.theme)), monitorDotWave(m.phase)) { + t.Fatal("paused observation animated") + } + } +} diff --git a/internal/ui/monitor_form_controls.go b/internal/ui/monitor_form_controls.go new file mode 100644 index 0000000..6a3df42 --- /dev/null +++ b/internal/ui/monitor_form_controls.go @@ -0,0 +1,42 @@ +package ui + +import ( + "github.com/merefield/codexometer/internal/i18n" + "time" +) + +func scheduleDate(t time.Time) string { + if i18n.Code() == "en-GB" { + return t.Format("02 Jan 2006 15:04 MST -07:00") + } + return t.Format("2006-01-02 15:04 MST -07:00") +} + +// All coordinates are display cells, never byte offsets. +type scheduleControl struct { + key string + x, row, width, focus, value int +} + +func (m Model) scheduleControlAt(x, y int) scheduleControl { + g := m.monitorDashboardLayout() + l := m.schedulePaneLayout(g.contentWidth, g.meterHeight) + row := y - g.meterY - 1 + if row < 0 || row >= l.textRows || row+l.scroll >= len(l.indices) { + return scheduleControl{} + } + m.width = g.contentWidth + _, controls := m.scheduleFormDocument() + for _, c := range controls { + if c.row == l.indices[row+l.scroll] && x >= 4+c.x && x < 4+c.x+c.width { + return c + } + } + return scheduleControl{} +} + +// Let the normal dashboard router handle navigation outside an embedded editor. +func (m Model) editorNavigationAt(x, y int) bool { + _, tab := m.mainTabAt(x, y) + return tab || m.headerActionAt(x, y) != "" || m.monitorAttentionAt(x, y) != "" +} diff --git a/internal/ui/monitor_form_controls_test.go b/internal/ui/monitor_form_controls_test.go new file mode 100644 index 0000000..1462042 --- /dev/null +++ b/internal/ui/monitor_form_controls_test.go @@ -0,0 +1,245 @@ +package ui + +import ( + "reflect" + "strings" + "testing" + "testing/synctest" + "time" + + tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/x/ansi" + "github.com/merefield/codexometer/internal/codex" +) + +// Also run in every locale subprocess: real rendered cells must match the +// interaction document, regardless of label length or double-width glyphs. +func TestScheduleRenderedControlSurfaces(t *testing.T) { + // The dashboard countdowns and delay preview use time.Now(). Keep the clock + // fixed across renders so a clock tick cannot resemble a hover-induced text + // change. Retain the whole-screen text comparison, not just the button. + synctest.Test(t, testScheduleRenderedControlSurfaces) +} + +func testScheduleRenderedControlSurfaces(t *testing.T) { + for _, width := range []int{48, 80, 140} { + for mode := 0; mode < 3; mode++ { + m, _ := promptTestModel() + m.width, m.height = width, 60 + m.openSchedule() + m.scheduleUI.mode = mode + m.scheduleUI.input.SetValue("A follow-up") + g := m.monitorDashboardLayout() + l := m.schedulePaneLayout(g.contentWidth, g.meterHeight) + copy := m + copy.width = g.contentWidth + _, controls := copy.scheduleFormDocument() + plain := strings.Split(ansi.Strip(m.render()), "\n") + modes := 0 + for _, c := range controls { + if strings.HasPrefix(c.key, "mode:") { + modes++ + } + row := -1 + for i, r := range l.indices { + if r == c.row { + row = g.meterY + 1 + i - l.scroll + break + } + } + if row < g.meterY+1 || row >= g.meterY+1+l.textRows { + continue + } + for _, x := range []int{4 + c.x, 4 + c.x + c.width - 1} { + if hit := m.scheduleControlAt(x, row); hit.key != c.key { + t.Fatalf("width %d mode %d hit %q, want %q at %d,%d", width, mode, hit.key, c.key, x, row) + } + } + if strings.TrimSpace(ansi.Cut(plain[row], 4+c.x, 4+c.x+c.width)) == "" { + t.Fatal("invisible control", c.key) + } + // One date suffices for hover; all dates still have their hit extents checked. + if strings.HasPrefix(c.key, "day:") && c.value != m.scheduleUI.date.Day() { + continue + } + before := m.render() + next, _ := m.Update(tea.MouseMotionMsg{X: 4 + c.x, Y: row}) + n := next.(Model) + after := n.render() + if n.scheduleUI.hover != c.key || before == after || ansi.Strip(before) != ansi.Strip(after) { + t.Fatal("hover missing or changed layout", width, mode, c.key) + } + if c.focus != 5 && c.focus != 6 { + next, _ = m.Update(tea.MouseClickMsg{X: 4 + c.x, Y: row, Button: tea.MouseLeft}) + if next.(Model).scheduleUI.focus != c.focus { + t.Fatal("click missed field", c.key) + } + } + } + if modes != 3 { + t.Fatal("clipping hid a trigger choice", width, mode) + } + } + } +} + +func TestScheduleDelayPreviewClockChangesOnlyPreview(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + m, _ := promptTestModel() + m.openSchedule() + m.scheduleUI.mode = 1 + m.scheduleUI.input.SetValue("A follow-up") + before, beforeControls := m.scheduleFormDocument() + previewRow := m.scheduleUI.actionRow() - 2 + if !strings.Contains(ansi.Strip(before[previewRow]), scheduleDate(m.scheduleUI.targetTime(time.Now()))) { + t.Fatal("delay preview does not show the current target time") + } + time.Sleep(time.Minute) // Preview has minute precision; no real sleep. + after, afterControls := m.scheduleFormDocument() + if before[previewRow] == after[previewRow] || !strings.Contains(ansi.Strip(after[previewRow]), scheduleDate(m.scheduleUI.targetTime(time.Now()))) { + t.Fatal("delay preview did not advance with the clock") + } + before[previewRow] = after[previewRow] + if !reflect.DeepEqual(before, after) || !reflect.DeepEqual(beforeControls, afterControls) { + t.Fatal("clock update changed form content or control geometry beyond the preview") + } + }) +} + +func clickFormTab(t *testing.T, m Model, tab mainTabID) Model { + t.Helper() + y := m.dashboardLayout().tabsY + for x := 0; x < m.width; x++ { + if hit, ok := m.mainTabAt(x, y); ok && hit == tab { + next, _ := m.Update(tea.MouseClickMsg{X: x, Y: y, Button: tea.MouseLeft}) + return next.(Model) + } + } + t.Fatal("tab missing") + return m +} + +func TestFormNavigationPreservesDraftsWithoutSubmitting(t *testing.T) { + m, _ := promptTestModel() + m.openSchedule() + m.scheduleUI.input.SetValue("Keep this schedule draft") + m.scheduleUI.mode, m.scheduleUI.hours, m.scheduleUI.minutes = 1, 7, 23 + m = clickFormTab(t, m, mainTabQuota) + if m.scheduleUI.open || m.meterView == viewMonitor || len(m.scheduleUI.queue.List("")) != 0 { + t.Fatal("navigation failed or saved a trigger") + } + m = clickFormTab(t, m, mainTabMonitor) + m.setRowContext("root-one", contextFull) + m.openSchedule() + if m.scheduleUI.input.Value() != "Keep this schedule draft" || m.scheduleUI.hours != 7 || m.scheduleUI.minutes != 23 || m.scheduleUI.mode != 1 { + t.Fatal("schedule draft lost") + } + m.closeSchedule() + m, c := queueTestModel(false) + m.openQueueItem(0, false) + m.monitorQueue.input.SetValue("Unsent queue edit") + m = clickFormTab(t, m, mainTabQuota) + if m.monitorQueue.open || c.changed != "" { + t.Fatal("navigation submitted queue edit") + } + m = clickFormTab(t, m, mainTabMonitor) + m.setRowContext("root-one", contextFull) + m.openQueueItem(0, false) + if m.monitorQueue.input.Value() != "Unsent queue edit" { + t.Fatal("queue draft lost") + } +} + +func TestScheduleSmallPaneKeepsComposerAndFooter(t *testing.T) { + for _, height := range []int{18, 22, 28} { + m, _ := promptTestModel() + m.width, m.height = 44, height + m.openSchedule() + m.scheduleUI.input.SetValue("Draft") + if !strings.Contains(ansi.Strip(m.render()), "Draft") { + t.Fatal("composer disappeared", height) + } + y := m.dashboardLayout().footerY + 1 + found := false + for x := 0; x < m.width; x++ { + if m.footerButtonAt(x, y) != footerButtonTheme { + continue + } + next, _ := m.Update(tea.MouseClickMsg{X: x, Y: y, Button: tea.MouseLeft}) + if next.(Model).theme == m.theme || !next.(Model).scheduleUI.open { + t.Fatal("small pane swallowed theme click") + } + found = true + break + } + if !found { + t.Fatal("footer missing", height) + } + } +} + +func TestScheduleSingleHintAndQueueEmbeddedKeyboard(t *testing.T) { + m, _ := promptTestModel() + m.openSchedule() + if strings.Count(ansi.Strip(m.render()), m.scheduleUI.focusHint()) != 1 { + t.Fatal("duplicate focus hints") + } + m, c := queueTestModel(false) + before := strings.Split(ansi.Strip(m.render()), "\n") + m.openQueueItem(0, false) + after := strings.Split(ansi.Strip(m.render()), "\n") + for i := 0; i < m.monitorDashboardLayout().meterY; i++ { + if before[i] != after[i] { + t.Fatal("queue editor replaced dashboard", i) + } + } + m, _, _ = m.updateMonitorQueue(tea.KeyPressMsg{Code: tea.KeyTab}) + if m.monitorQueue.editorFocus != 1 || m.monitorQueue.input.Focused() { + t.Fatal("save not keyboard focusable") + } + m, _, _ = m.updateMonitorQueue(tea.KeyPressMsg{Code: tea.KeyTab}) + m, _, _ = m.updateMonitorQueue(tea.KeyPressMsg{Code: tea.KeyEnter}) + if m.monitorQueue.open || c.changed != "" { + t.Fatal("keyboard cancel wrote queue") + } +} + +func TestScheduleInvisibleConfirmationCannotSave(t *testing.T) { + m, _ := promptTestModel() + m.openSchedule() + m.scheduleUI.input.SetValue("Do not send blindly") + m.scheduleUI.focus = 5 + m.width, m.height = 28, 14 + m, cmd, _ := m.updateSchedule(tea.KeyPressMsg{Code: tea.KeyEnter}) + if cmd != nil || len(m.scheduleUI.queue.List("")) != 0 || !m.scheduleUI.open || m.scheduleUI.notice == "" { + t.Fatal("invisible confirmation accepted") + } +} + +func TestScheduleAttentionNavigationKeepsDraft(t *testing.T) { + m, _ := promptTestModel() + m.height = 60 + m.monitorSessionData[0].attention = codex.SessionAttentionComplete + m.monitorSessionData[0].working = false + m.openSchedule() + m.scheduleUI.input.SetValue("Unsaved attention draft") + g := m.monitorDashboardLayout() + for y := 0; y < g.meterY; y++ { + for x := 0; x < m.width; x++ { + if m.monitorAttentionAt(x, y) == "" { + continue + } + next, _ := m.Update(tea.MouseClickMsg{X: x, Y: y, Button: tea.MouseLeft}) + m = next.(Model) + if m.scheduleUI.open { + t.Fatal("attention pill swallowed") + } + m.openSchedule() + if m.scheduleUI.input.Value() != "Unsaved attention draft" { + t.Fatal("attention navigation lost draft") + } + return + } + } + t.Fatal("attention pill missing from fixture") +} diff --git a/internal/ui/monitor_name_test.go b/internal/ui/monitor_name_test.go index 04c6ed4..03273f3 100644 --- a/internal/ui/monitor_name_test.go +++ b/internal/ui/monitor_name_test.go @@ -31,6 +31,29 @@ func TestMonitorSessionNameDisplayAndRename(t *testing.T) { } } +func TestFullDetailAndComposerIdentifySession(t *testing.T) { + for _, working := range []bool{false, true} { + m, _ := promptTestModel() + if working { + m, _ = turnTestModel() + } + m.monitorSessionData[0].name = "Fix the dashboard" + identity := shortSessionID(m.monitorSessionData[0].id) + " // Fix the dashboard" + for _, name := range []string{"Fix the dashboard", ""} { + m.monitorSessionData[0].name = name + if name == "" { + m.monitorSessionData[0].workingDirectory = "/work/dashboard" + identity = shortSessionID(m.monitorSessionData[0].id) + " // dashboard" + } + frame := strings.Split(ansi.Strip(m.renderMonitorContextDetail(160, 40, paletteFor(m.theme))), "\n")[0] + composer := strings.Split(ansi.Strip(m.renderMonitorPrompt(160, 40, paletteFor(m.theme))), "\n")[0] + if !strings.Contains(frame, identity) || !strings.Contains(composer, identity) { + t.Fatalf("missing session identity: frame=%q composer=%q", frame, composer) + } + } + } +} + func TestNamedSessionPillAndBadgeNavigation(t *testing.T) { for _, attention := range []codex.SessionAttention{codex.SessionAttentionComplete, codex.SessionAttentionApproval, codex.SessionAttentionInput} { m := Model{meterView: viewMonitor, width: 140, height: 40, snapshot: codex.DemoSnapshot(), monitorState: monitorRunning} diff --git a/internal/ui/monitor_prompt.go b/internal/ui/monitor_prompt.go index 3034f17..e163b9e 100644 --- a/internal/ui/monitor_prompt.go +++ b/internal/ui/monitor_prompt.go @@ -3,6 +3,7 @@ package ui import ( "context" "fmt" + "maps" "strings" "time" @@ -13,14 +14,52 @@ import ( ) type monitorPromptState struct { - session string - offer codex.SessionPromptOffer - input monitorEditor - answers [3]string - question int - choice int - busy bool - notice string + session string + offer codex.SessionPromptOffer + input monitorEditor + answers [3]string + question int + choice int + busy bool + notice string + noticeUntil time.Time +} + +// Retain only unsent ordinary text, never secret/question answers or in-flight +// submissions. Stored drafts have no reusable server capability or focus. +func (m *Model) stashMonitorDraft() { + p := m.monitorPrompt + if p.session == "" || p.busy || len(p.offer.Questions) != 0 || p.input.secret { + return + } + m.monitorDrafts = maps.Clone(m.monitorDrafts) + if text := p.input.Value(); text != "" { + if m.monitorDrafts == nil { + m.monitorDrafts = make(map[string]string) + } + m.monitorDrafts[p.session] = text + } else { + delete(m.monitorDrafts, p.session) + } +} + +func (m *Model) restoreMonitorDraft() { + if m.monitorPrompt.session != "" || m.meterView != viewMonitor || m.contextTargetHidden() { + return + } + id := m.monitorContextTarget() + text := m.monitorDrafts[id] + if text == "" { + return + } + o := m.monitorPromptOffer() + if o.Token == "" || len(o.Questions) != 0 { + return + } + m.monitorPrompt = monitorPromptState{session: id, offer: o, input: newMonitorEditor(), choice: -1} + m.monitorPrompt.input.SetValue(text) + m.monitorDrafts = maps.Clone(m.monitorDrafts) + delete(m.monitorDrafts, id) } type monitorPromptResult struct { @@ -28,6 +67,12 @@ type monitorPromptResult struct { err error } +type monitorTurnResult struct { + session, token, action string + text string + err error +} + func (m Model) monitorPromptOffer() codex.SessionPromptOffer { if m.meterView != viewMonitor || m.monitorContextTarget() == "" || m.contextTargetHidden() || (m.monitorContextDetail == "" && (m.monitorSelectedID != m.monitorContextTarget() || m.rowContextMode(m.monitorSelectedID) != contextWide)) { @@ -54,6 +99,11 @@ func (m Model) monitorPromptOffer() codex.SessionPromptOffer { thread = s.preview.ThreadID } o := c.SessionPrompt(thread) + if o.Token == "" { + if active, ok := m.fetcher.(codex.SessionTurnClient); ok { + o = active.SessionTurn(thread) + } + } // Structured questions retain the full-detail interface. if m.monitorContextDetail == "" && len(o.Questions) > 0 { return codex.SessionPromptOffer{} @@ -85,7 +135,7 @@ func (m Model) monitorPromptRows(width, height int) int { if m.monitorPromptOffer().Token != "" || m.monitorPrompt.session == m.monitorContextTarget() && (m.monitorPrompt.busy || m.monitorPrompt.notice != "" && !sentNotice(m.monitorPrompt.notice)) { p := m.monitorPrompt rows := 3 - if p.input.Focused() && !p.busy { + if (p.input.Focused() || p.input.Value() != "") && !p.busy { p.input.configure(width, m.monitorPromptEditorHeight(width, height)) rows = p.input.Height() + 2 } @@ -129,13 +179,23 @@ func (m Model) monitorPromptSize() (int, int) { } func (m *Model) focusMonitorPrompt() tea.Cmd { + m.restoreMonitorDraft() o := m.monitorPromptOffer() w, h := m.monitorPromptSize() if o.Token == "" || m.monitorPrompt.busy || m.monitorPromptRows(w, h) == 0 { return nil } if m.monitorPrompt.offer.Token != o.Token { - m.monitorPrompt = monitorPromptState{session: m.monitorContextTarget(), offer: o, input: newMonitorEditor(), choice: -1} + if m.monitorPrompt.offer.Token != "" && m.monitorPrompt.session == m.monitorContextTarget() && len(o.Questions) == 0 && len(m.monitorPrompt.offer.Questions) == 0 { + m.monitorPrompt.offer = o + } else { + m.monitorPrompt = monitorPromptState{session: m.monitorContextTarget(), offer: o, input: newMonitorEditor(), choice: -1} + if len(o.Questions) == 0 { + m.monitorPrompt.input.SetValue(m.monitorDrafts[m.monitorPrompt.session]) + m.monitorDrafts = maps.Clone(m.monitorDrafts) + delete(m.monitorDrafts, m.monitorPrompt.session) + } + } } m.monitorPrompt.input.setSecret(len(o.Questions) > 0 && o.Questions[m.monitorPrompt.question].Secret) m.monitorPrompt.input.configure(w, m.monitorPromptEditorHeight(w, h)) @@ -146,18 +206,34 @@ func (m *Model) focusMonitorPrompt() tea.Cmd { func (m Model) renderMonitorPrompt(width, height int, colors palette) string { p := m.monitorPrompt o := m.monitorPromptOffer() - header := i18n.Text("FOLLOW-UP") + " // " + terminalLabel(o.ThreadID) + identity := shortSessionID(o.ThreadID) + if s, ok := m.contextDetailSession(); ok { + identity = monitorSessionIdentity(s) + } + header := i18n.Text("FOLLOW-UP") + " // " + identity + scheduling := m.monitorContextDetail != "" && o.Token != "" && len(o.Questions) == 0 && o.TurnID == "" 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) + header = fmt.Sprintf("%s %d/%d // %s // %s", i18n.Text("REPLY"), n+1, len(o.Questions), identity, o.Questions[n].Text) } - line := i18n.Text("[ Click here or press Enter to write ]") + line := colors.dimmed().Background(monitorComposerBackground(colors)). + Width(max(width-4, 1)).Render(ansi.Truncate(i18n.Text("[ Click here or press Enter to write ]"), max(width-4, 1), "")) hint := i18n.Text("Enter: send / next answer • Esc: leave editor • ↑/↓: choices") - if p.offer.Token == o.Token && p.input.Focused() && !p.busy { + if o.TurnID != "" { + header = i18n.Text("WORKING") + if s, ok := m.contextDetailSession(); ok { + if dots := m.sessionActivityDots(s); dots != "" { + header += " " + dots + } + } + header += " // " + i18n.Text("FOLLOW-UP") + " // " + identity + hint = "Enter: steer • Tab: queue next turn • Esc: interrupt" + } + if p.offer.Token == o.Token && (p.input.Focused() || p.input.Value() != "") && !p.busy { input := p.input input.configure(width, m.monitorPromptEditorHeight(width, height)) line = input.View(colors) @@ -168,19 +244,44 @@ func (m Model) renderMonitorPrompt(width, height int, colors palette) string { if p.notice != "" { if !sentNotice(p.notice) { hint = p.notice - } else if feedback := m.detailFeedback(); feedback != "" { - hint = feedback + } else if time.Now().Before(p.noticeUntil) || m.monitorDetailSent.session == m.monitorContextTarget() && time.Now().Before(m.monitorDetailSent.visibleUntil) { + hint = p.notice } } 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), "…")) + heading := colors.label().Render(ansi.Truncate(header, max(width-4, 1), "…")) + if scheduling { + heading = m.renderScheduleToggleHeader(width, identity, false, colors) + } + return heading + "\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) { p := &m.monitorPrompt + if result, ok := msg.(monitorTurnResult); ok { + if p.session == result.session && p.offer.Token == result.token { + p.busy = false + if result.err != nil { + p.notice = "Action unconfirmed or unsupported; check Codex before retrying. Draft retained." + } else if result.action == "interrupt" { + p.notice = "Interrupt requested. Draft retained." + } else { + p.input.Reset() + p.notice = i18n.Text("Text sent ...") + p.noticeUntil = time.Now().Add(3 * time.Second) + m.recordDetailSent(p.notice) + if result.action == "queue" { + p.notice = "Queued in Codex for the next turn." + cmd := m.recordQueuedSubmission(result.session, result.text) + return m, cmd, true + } + } + } + return m, nil, true + } if result, ok := msg.(monitorPromptResult); ok { if p.session == result.session && p.offer.Token == result.token { p.busy = false @@ -188,6 +289,7 @@ func (m Model) updateMonitorPrompt(msg tea.Msg) (Model, tea.Cmd, bool) { p.input.Blur() p.answers = [3]string{} p.notice = i18n.Text("Text sent ...") + p.noticeUntil = time.Now().Add(3 * time.Second) if result.err == nil { m.recordDetailSent(p.notice) } @@ -198,6 +300,12 @@ func (m Model) updateMonitorPrompt(msg tea.Msg) (Model, tea.Cmd, bool) { } return m, nil, true } + if !p.input.ready && p.session == "" { + m.restoreMonitorDraft() + if !p.input.ready { + return m, nil, false + } + } wasFocused := p.input.Focused() layout := m if size, ok := msg.(tea.WindowSizeMsg); ok { @@ -209,9 +317,26 @@ func (m Model) updateMonitorPrompt(msg tea.Msg) (Model, tea.Cmd, bool) { p.input.Blur() } if p.session != "" && (m.meterView != viewMonitor || m.monitorContextTarget() != p.session || m.contextTargetHidden() || m.monitorContextDetail == "" && m.monitorSelectedID != p.session) { + m.stashMonitorDraft() *p = monitorPromptState{} + m.restoreMonitorDraft() } 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.")} + next := m.monitorPromptOffer() + if len(p.offer.Questions) == 0 && len(next.Questions) == 0 && (p.offer.TurnID != "" || next.TurnID != "") { + // Keep ordinary drafts across working/idle transitions, but never + // reinterpret the key that arrived during a capability change. + if next.Token != "" { + p.offer = next + } else { + p.input.Blur() + } + if _, key := msg.(tea.KeyPressMsg); key { + return m, nil, true + } + } else { + m.stashMonitorDraft() + *p = monitorPromptState{session: p.session, notice: i18n.Text("Prompt changed; review the session before replying.")} + } } if wasFocused && !p.input.Focused() { switch msg.(type) { @@ -219,16 +344,32 @@ func (m Model) updateMonitorPrompt(msg tea.Msg) (Model, tea.Cmd, bool) { return m, nil, true } } + if p.busy && 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": + if key.String() == "esc" && p.offer.TurnID != "" { + return m.submitMonitorTurn("interrupt") + } p.input.Blur() return m, nil, true case "enter": + if p.offer.TurnID != "" { + return m.submitMonitorTurn("steer") + } return m.submitMonitorPrompt() + case "tab": + if p.offer.TurnID != "" { + return m.submitMonitorTurn("queue") + } case "up", "down": if len(p.offer.Questions) > 0 { options := p.offer.Questions[p.question].Options @@ -262,6 +403,23 @@ func (m Model) updateMonitorPrompt(msg tea.Msg) (Model, tea.Cmd, bool) { return m, nil, false } +func (m Model) submitMonitorTurn(action string) (Model, tea.Cmd, bool) { + p := &m.monitorPrompt + o := m.monitorPromptOffer() + c, ok := m.fetcher.(codex.SessionTurnClient) + text := strings.TrimSpace(p.input.Value()) + if !ok || p.busy || o.Token == "" || o.Token != p.offer.Token || o.TurnID == "" || action != "interrupt" && text == "" { + return m, nil, true + } + p.busy, p.notice = true, "" + session := p.session + return m, func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), 6*time.Second) + defer cancel() + return monitorTurnResult{session: session, token: o.Token, action: action, text: text, err: c.SendSessionTurn(ctx, o, action, text)} + }, true +} + func (m Model) submitMonitorPrompt() (Model, tea.Cmd, bool) { p := &m.monitorPrompt if p.busy || p.offer.Token == "" || m.monitorPromptOffer().Token != p.offer.Token { diff --git a/internal/ui/monitor_prompt_test.go b/internal/ui/monitor_prompt_test.go index ac8ce87..3748b65 100644 --- a/internal/ui/monitor_prompt_test.go +++ b/internal/ui/monitor_prompt_test.go @@ -3,16 +3,51 @@ package ui import ( "context" "errors" + "image" "strings" "testing" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" + uv "github.com/charmbracelet/ultraviolet" "github.com/charmbracelet/x/ansi" "github.com/merefield/codexometer/internal/codex" "github.com/merefield/codexometer/internal/i18n" ) +func TestMonitorPromptPlaceholderComposerBackground(t *testing.T) { + for theme := themeHacker; theme < themeCount; theme++ { + for _, width := range []int{24, 80, 120} { + m, _ := promptTestModel() + colors := paletteFor(theme) + placeholder := m.renderMonitorPrompt(width, 30, colors) + cells := uv.NewScreenBuffer(width, 6) + uv.NewStyledString(placeholder).Draw(&cells, image.Rect(0, 0, width, 6)) + editor := newMonitorEditor() + editor.configure(width, 30) + editor.SetValue("Typing") + editor.Focus() + active := uv.NewScreenBuffer(width, 3) + uv.NewStyledString(editor.View(colors)).Draw(&active, image.Rect(0, 0, width, 3)) + want := active.CellAt(3, 0).Style.Bg + if want == nil { + t.Fatal("composer has no background") + } + for x := 0; x < width-4; x++ { + cell := cells.CellAt(x, 1) + if cell == nil || cell.Style.Bg == nil { + t.Fatalf("theme %d width %d: missing placeholder background at %d", theme, width, x) + } + wr, wg, wb, wa := want.RGBA() + r, g, b, a := cell.Style.Bg.RGBA() + if r != wr || g != wg || b != wb || a != wa { + t.Fatalf("theme %d width %d: placeholder and composer backgrounds differ at %d", theme, width, x) + } + } + } + } +} + type promptTestClient struct { offer codex.SessionPromptOffer answers []string @@ -152,8 +187,8 @@ func TestMonitorPromptStaleFocusPasteAndEsc(t *testing.T) { } m.focusMonitorPrompt() m.stepBackMonitorContext() - if m.monitorPrompt.input.Value() != "" || m.monitorPrompt.input.Focused() { - t.Fatal("draft leaked to another mode") + if m.monitorPrompt.input.Value() != "Hello\nWORLD" || m.monitorPrompt.input.Focused() || m.monitorPrompt.offer.Token != c.offer.Token { + t.Fatal("ordinary draft was not retained with a fresh capability and released focus") } } diff --git a/internal/ui/monitor_queue.go b/internal/ui/monitor_queue.go new file mode 100644 index 0000000..23e699d --- /dev/null +++ b/internal/ui/monitor_queue.go @@ -0,0 +1,702 @@ +package ui + +import ( + "context" + "maps" + "strconv" + "strings" + "time" + + tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/x/ansi" + "github.com/merefield/codexometer/internal/codex" + "github.com/merefield/codexometer/internal/i18n" + "github.com/merefield/codexometer/internal/schedule" +) + +type followupEntry struct { + label, text string + receipt string + native *codex.SessionQueuedMessage + trigger *schedule.Job + editable, removable, now bool +} + +func (entry followupEntry) key() string { + if entry.receipt != "" { + return entry.receipt + } + if entry.native != nil { + return "native:" + entry.native.ID + } + return "scheduled:" + entry.trigger.ID +} + +func (m Model) followupEntries() []followupEntry { + var entries []followupEntry + if m.monitorQueue.session == m.monitorContextTarget() { + for _, item := range m.monitorQueue.items { + entries = append(entries, followupEntry{label: i18n.Text("QUEUED"), text: item.Text, native: &item, editable: item.Editable && !m.monitorQueue.stale, removable: !m.monitorQueue.stale}) + } + for i, text := range m.monitorQueue.submitted { + entries = append(entries, followupEntry{label: i18n.Text("ACCEPTED // CHECKING QUEUE"), text: text, receipt: "receipt:" + strconv.Itoa(i)}) + } + } + if j, ok := m.pendingTrigger(); ok { + label := i18n.Text("AFTER QUOTA REFRESH") + if j.Trigger == "at" { + label = i18n.Text("AT ") + scheduleDate(j.At.Local()) + } + if j.Status != "pending" { + label += " // " + strings.ToUpper(i18n.Text(j.Status)) + } + label = i18n.Text("SCHEDULED · ") + label + entries = append(entries, followupEntry{label: label, text: j.Text, trigger: &j, editable: j.Status == "pending", removable: j.Status != "sending", now: m.triggerReady(j)}) + } + return entries +} + +func followupButtons(entry followupEntry, width int) []triggerButton { + var buttons []triggerButton + if entry.trigger != nil { + buttons = append(buttons, triggerButton{text: i18n.Text("[NOW]"), key: "now", enabled: entry.now}) + } + buttons = append(buttons, triggerButton{text: i18n.Text("[EDIT]"), key: "edit", enabled: entry.editable}, triggerButton{text: "[×]", key: "delete", enabled: entry.removable}) + total := len(buttons) - 1 + for _, b := range buttons { + total += ansi.StringWidth(b.text) + } + x := max(width-4-total, 0) + for i := range buttons { + buttons[i].x = x + x += ansi.StringWidth(buttons[i].text) + 1 + } + return buttons +} + +type monitorQueueState struct { + hover string + focusSession string + session string + items []codex.SessionQueuedMessage + submitted []string // acknowledged sends, not yet reconciled with a fresh list + loading, busy, open, deleting, focused, stale bool + request uint64 + revision uint64 + next time.Time + offset int + item codex.SessionQueuedMessage + input monitorEditor + notice string + composerRows int + editorFocus int + drafts map[string]queueEditDraft +} + +type queueEditDraft struct{ original, text string } +type monitorQueueResult struct { + session string + request, revision uint64 + items []codex.SessionQueuedMessage + err error +} +type monitorQueueChanged struct{ err error } + +func (m *Model) recordQueuedSubmission(session, text string) tea.Cmd { + q := &m.monitorQueue + if q.session != session { + q.items, q.submitted = nil, nil + q.offset, q.focused = 0, false + } + q.session = session + q.submitted = append(q.submitted, codex.SanitizeSessionContext(text)) + // A list started before this acknowledgement cannot resolve this receipt. + q.request++ + q.loading = false + q.next = time.Time{} + return m.pollMonitorQueue() +} + +func (m Model) queueUnavailable() bool { + return m.monitorQueue.session == m.monitorContextTarget() && m.monitorQueue.stale +} + +func (m *Model) pollMonitorQueue() tea.Cmd { + q := &m.monitorQueue + c, ok := m.fetcher.(codex.SessionQueueClient) + id := m.monitorContextTarget() + if !ok || m.meterView != viewMonitor || id == "" || m.contextTargetHidden() || q.open || q.busy || q.loading || m.scheduleUI.open { + return nil + } + if m.monitorContextDetail == "" && (m.monitorSelectedID != id || m.rowContextMode(id) != contextWide) { + return nil + } + revision := c.SessionQueueRevision() + if q.session == id && revision == q.revision && time.Now().Before(q.next) { + return nil + } + if q.session != id { + q.items = nil + q.submitted = nil + q.stale = false + q.offset = 0 + q.focused = false + } + q.session = id + q.loading = true + q.request++ + request := q.request + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), 6*time.Second) + defer cancel() + items, err := c.SessionQueue(ctx, id) + return monitorQueueResult{id, request, revision, items, err} + } +} + +// Controls remain anchored at the bottom. The queue occupies only spare space +// above them, so approval/composer click targets don't move. +func (m Model) monitorQueueRows(width, height, controlRows int) int { + id := m.monitorContextTarget() + entries := m.followupEntries() + if m.meterView != viewMonitor || m.contextTargetHidden() || len(entries) == 0 && !m.queueUnavailable() || width < 34 { + return 0 + } + if s, ok := m.contextDetailSession(); !ok || m.hasSessionProfile(s) { + return 0 + } + if m.monitorContextDetail == "" && (m.monitorSelectedID != id || m.rowContextMode(id) != contextWide) { + return 0 + } + available := height - controlRows - 7 // preserve borders, gap and at least four context rows + limit := 6 + if m.monitorContextDetail == "" { + s, _ := m.contextDetailSession() + textRows, _, _ := monitorContextBodyLayout(height, controlRows) + // Even long commentary leaves a compact queue/status line. Never + // take space from approval buttons or the composer. + available = min(available, max(1, textRows-len(expandedContextLines(width, s)))) + limit = 2 + } + if available < 1 { + return 0 + } + return min(available, limit, len(entries)+1) +} + +func (m Model) renderMonitorQueue(width, rows int, colors palette) string { + if rows == 0 { + return "" + } + q := m.monitorQueue + entries := m.followupEntries() + start := min(q.offset, max(len(entries)-(rows-1), 0)) + header := i18n.Format("FOLLOW-UPS // %d", len(entries)) + if m.queueUnavailable() { + header = i18n.Text("FOLLOW-UPS // UNAVAILABLE") + } + if q.focused { + header += i18n.Text(" // ↑↓ · Enter: edit · X: delete · Esc") + } else { + header += " // Alt+Q" + } + if rows == 1 { + header += i18n.Text(" // open follow-ups") + } else if len(entries) > rows-1 { + header += i18n.Format(" // %d–%d // scroll", start+1, min(start+rows-1, len(entries))) + } + inner := max(width-4, 1) + tint := monitorTintBackground(colors, 6) + header = ansi.Truncate("─ "+header, inner, "…") + if spare := inner - ansi.StringWidth(header); spare > 1 { + header += " " + strings.Repeat("─", spare-1) + } + lines := []string{colors.label().Bold(true).Foreground(colors.primary).Background(tint).Width(inner).Render(header)} + for i := start; i < min(start+rows-1, len(entries)); i++ { + entry := entries[i] + buttons := followupButtons(entry, width) + selected := q.focused && i == q.offset + marker := " " + background := tint + if selected { + marker = "› " + background = monitorTintBackground(colors, 18) + } + text := marker + entry.label + " // " + strings.Join(strings.Fields(entry.text), " ") + space := max(buttons[0].x-1, 1) + text = ansi.Truncate(text, space, "…") + text += strings.Repeat(" ", max(space-ansi.StringWidth(text), 0)) + prefix := colors.label().Background(background) + if selected { + prefix = prefix.Foreground(colors.primary).Bold(true) + } + line := prefix.Render(text) + for _, b := range buttons { + style := colors.label().Background(background) + if !b.enabled { + style = colors.dimmed().Background(background) + } else if m.monitorContextHover == "queue:"+b.key+":"+strconv.Itoa(i) { + style = style.Foreground(colors.background).Background(colors.primary) + } + line += colors.label().Background(background).Render(" ") + style.Render(b.text) + } + lines = append(lines, colors.label().Background(background).Width(inner).Render(line)) + } + return strings.Join(lines, "\n") +} + +func (m Model) monitorQueueAction(width, height, controls, x, y int) string { + rows := m.monitorQueueRows(width, height, controls) + if rows == 0 { + return "" + } + _, _, top := monitorContextBodyLayout(height, controls+rows) + if x < 2 || x >= width-2 || y < top || y >= top+rows { + return "" + } + if y == top { + return "queue:focus" + } + entries := m.followupEntries() + start := min(m.monitorQueue.offset, max(len(entries)-(rows-1), 0)) + i := start + y - top - 1 + if i >= len(entries) { + return "queue:focus" + } + for _, b := range followupButtons(entries[i], width) { + if b.enabled && x >= 2+b.x && x < 2+b.x+ansi.StringWidth(b.text) { + return "queue:" + b.key + ":" + strconv.Itoa(i) + } + } + return "queue:focus" +} + +func (m Model) monitorQueueAt(x, y int) string { + if m.meterView != viewMonitor || m.contextTargetHidden() || len(m.followupEntries()) == 0 && !m.queueUnavailable() { + return "" + } + g := m.monitorDashboardLayout() + x -= 2 + y -= g.meterY + if m.monitorContextDetail != "" { + base := m.layoutDetailControlsBase(g.contentWidth, g.meterHeight) + return m.monitorQueueAction(g.contentWidth, g.meterHeight, base.rows, x, y) + } + a := m.monitorArea(g.contentWidth, g.meterHeight) + sessions, heights, _ := m.monitorSessionPage(a.graphHeight) + rowY := a.topHeight + a.gap - 1 + mw, cw, _ := monitorSessionColumnWidths(a.width) + for i, s := range sessions { + if s.id == m.monitorSelectedID && m.rowContextMode(s.id) == contextWide { + return m.monitorQueueAction(cw, heights[i], m.expandedControlRows(cw, heights[i], s), x-mw-1, y-rowY) + } + rowY += heights[i] + } + return "" +} + +func (m *Model) openQueueItem(index int, remove bool) tea.Cmd { + q := &m.monitorQueue + if q.busy || q.loading || q.stale || q.session != m.monitorContextTarget() || index < 0 || index >= len(q.items) { + return nil + } + item := q.items[index] + if !remove && !item.Editable { + return nil + } + q.item = item + q.open = true + q.deleting = remove + q.focused = false + q.editorFocus = 0 + if remove { + q.editorFocus = 1 + } + q.notice = "" + q.input = newMonitorEditor() + q.input.SetValue(item.Text) + key := q.session + "\x00" + item.ID + if draft, ok := q.drafts[key]; ok && draft.original == item.Text && !remove { + q.input.SetValue(draft.text) + } + q.drafts = maps.Clone(q.drafts) + delete(q.drafts, key) + m.setRowContext(q.session, contextFull) + g := m.monitorDashboardLayout() + q.composerRows = max(3, m.monitorPromptRows(g.contentWidth, g.meterHeight)) + m.monitorPrompt.input.Blur() + if remove { + return nil + } + return q.input.Focus() +} + +func (m *Model) followupAction(index int, action string) tea.Cmd { + m.monitorQueue.focused = false + entries := m.followupEntries() + if index < 0 || index >= len(entries) { + return nil + } + entry := entries[index] + if entry.receipt != "" { + return nil + } + if entry.trigger != nil { + switch action { + case "edit": + if !entry.editable { + return nil + } + m.setRowContext(entry.trigger.Session, contextFull) + return m.openSchedule() + case "delete": + if entry.removable { + return m.triggerAction("ctrl+d") + } + case "now": + if entry.now { + m.setRowContext(entry.trigger.Session, contextFull) + return m.triggerAction("ctrl+n") + } + } + return nil + } + return m.openQueueItem(index, action == "delete") +} + +func (m Model) updateMonitorQueue(msg tea.Msg) (Model, tea.Cmd, bool) { + q := &m.monitorQueue + switch v := msg.(type) { + case monitorQueueResult: + if v.request == q.request { + selected := "" + before := m.followupEntries() + if q.focused && q.offset < len(before) { + selected = before[q.offset].key() + } + q.loading = false + q.revision = v.revision + q.next = time.Now().Add(2 * time.Second) + q.stale = v.err != nil + if v.err != nil { + q.next = time.Now().Add(10 * time.Second) + } else { + q.items = v.items + q.submitted = nil + } + after := m.followupEntries() + q.offset = min(q.offset, max(len(after)-1, 0)) + if selected != "" { + q.focused = false + for i, entry := range after { + if entry.key() == selected { + q.offset = i + q.focused = true + break + } + } + } + } + return m, nil, true + case monitorQueueChanged: + q.busy = false + if v.err != nil { + q.notice = i18n.Text("Decision unconfirmed; check Codex. Do not retry here.") + } else { + q.open = false + q.input.Blur() + q.items = nil + q.next = time.Time{} + } + return m, nil, true + } + if m.scheduleUI.open { + q.focused = false + return m, nil, false + } + if !q.open && !q.focused { + switch msg.(type) { + case tea.MouseMsg, tea.KeyPressMsg: + default: + return m, nil, false + } + } + if q.open { + g := m.monitorDashboardLayout() + q.input.configure(g.contentWidth, max(q.composerRows, 3)+6) + if mouse, ok := msg.(tea.MouseMsg); ok { + point := mouse.Mouse() + if m.editorNavigationAt(point.X, point.Y) && !q.busy { + if _, click := msg.(tea.MouseClickMsg); click && point.Button == tea.MouseLeft { + if !q.deleting { + q.drafts = maps.Clone(q.drafts) + if q.drafts == nil { + q.drafts = map[string]queueEditDraft{} + } + q.drafts[q.session+"\x00"+q.item.ID] = queueEditDraft{original: q.item.Text, text: q.input.Value()} + } + q.open = false + q.input.Blur() + } + return m, nil, false + } + m.hoveredButton = m.footerButtonAt(point.X, point.Y) + if _, click := msg.(tea.MouseClickMsg); click && point.Button == tea.MouseLeft && m.hoveredButton != footerButtonNone { + next, cmd := m.pressFooterButton(m.hoveredButton) + return next.(Model), cmd, true + } + } + if key, ok := msg.(tea.KeyPressMsg); ok { + if key.String() == "ctrl+c" || key.String() == "q" && q.editorFocus != 0 { + return m, tea.Quit, true + } + if q.busy { + return m, nil, true + } + switch key.String() { + case "esc": + q.open = false + q.input.Blur() + q.next = time.Time{} + return m, nil, true + case "enter": + if q.editorFocus == 2 { + q.open = false + q.input.Blur() + q.next = time.Time{} + return m, nil, true + } + return m.changeQueueItem() + case "tab", "shift+tab": + delta := 1 + if key.String() == "shift+tab" { + delta = -1 + } + q.editorFocus = (q.editorFocus + delta + 3) % 3 + if q.deleting && q.editorFocus == 0 { + q.editorFocus = (q.editorFocus + delta + 3) % 3 + } + q.input.Blur() + if q.editorFocus == 0 { + return m, q.input.Focus(), true + } + return m, nil, true + case "t", "r": + if q.editorFocus != 0 { + button := footerButtonTheme + if key.String() == "r" { + button = footerButtonRefresh + } + next, cmd := m.pressFooterButton(button) + return next.(Model), cmd, true + } + } + } + if mouse, ok := msg.(tea.MouseMsg); ok { + point := mouse.Mouse() + q.hover = "" + for _, b := range m.queueEditorButtons() { + if b.enabled && point.Y == b.y && point.X >= b.x && point.X < b.x+ansi.StringWidth(b.text) { + q.hover = b.key + } + } + if _, click := msg.(tea.MouseClickMsg); click && point.Button == tea.MouseLeft { + if q.hover == "save" { + return m.changeQueueItem() + } + if q.hover == "cancel" { + q.open = false + q.input.Blur() + q.next = time.Time{} + } + _, _, editorY := monitorContextBodyLayout(g.meterHeight, min(max(q.composerRows, 3), max(g.meterHeight-5, 1))) + if q.hover == "" && !q.busy && !q.deleting && point.X >= 4 && point.X < g.contentWidth && point.Y >= g.meterY+editorY && point.Y < g.meterY+g.meterHeight-3 { + q.editorFocus = 0 + return m, q.input.Focus(), true + } + } + return m, nil, true + } + if !q.deleting && !q.busy && q.editorFocus == 0 { + var cmd tea.Cmd + q.input, cmd = q.input.Update(msg) + if cmd != nil { + return m, cmd, true + } + } + switch msg.(type) { + case tea.KeyPressMsg, tea.PasteMsg, tea.MouseWheelMsg: + return m, nil, true + } + return m, nil, false + } + if m.meterView != viewMonitor || m.monitorContextTarget() == "" || m.contextTargetHidden() { + q.focused = false + return m, nil, false + } + if q.focused && q.focusSession != m.monitorContextTarget() { + q.focused = false + } + if key, ok := msg.(tea.KeyPressMsg); ok { + if key.String() == "alt+q" && len(m.followupEntries()) > 0 { + if m.monitorContextDetail == "" { + m.setRowContext(m.monitorContextTarget(), contextFull) + } + q.focused = true + q.focusSession = m.monitorContextTarget() + m.monitorPrompt.input.Blur() + return m, nil, true + } + if q.focused { + switch key.String() { + case "esc", "tab": + q.focused = false + case "up": + q.offset = max(q.offset-1, 0) + case "down": + q.offset = min(q.offset+1, max(len(m.followupEntries())-1, 0)) + case "pgup": + q.offset = max(q.offset-5, 0) + case "pgdown": + q.offset = min(q.offset+5, max(len(m.followupEntries())-1, 0)) + case "enter", "e": + cmd := m.followupAction(q.offset, "edit") + return m, cmd, true + case "delete", "x": + cmd := m.followupAction(q.offset, "delete") + return m, cmd, true + } + return m, nil, true + } + } + if mouse, ok := msg.(tea.MouseMsg); ok { + point := mouse.Mouse() + hit := m.monitorQueueAt(point.X, point.Y) + if hit != "" { + if _, wheel := msg.(tea.MouseWheelMsg); wheel { + if point.Button == tea.MouseWheelUp { + q.offset = max(q.offset-1, 0) + } else if point.Button == tea.MouseWheelDown { + q.offset = min(q.offset+1, max(len(m.followupEntries())-1, 0)) + } + return m, nil, true + } + if _, click := msg.(tea.MouseClickMsg); click && point.Button == tea.MouseLeft { + parts := strings.Split(hit, ":") + if len(parts) == 3 { + i, _ := strconv.Atoi(parts[2]) + cmd := m.followupAction(i, parts[1]) + return m, cmd, true + } + if m.monitorContextDetail == "" { + m.setRowContext(m.monitorContextTarget(), contextFull) + } + q.focused = true + q.focusSession = m.monitorContextTarget() + m.monitorPrompt.input.Blur() + return m, nil, true + } + } else if _, click := msg.(tea.MouseClickMsg); click { + q.focused = false + } + } + return m, nil, false +} + +func (m Model) changeQueueItem() (Model, tea.Cmd, bool) { + q := &m.monitorQueue + c, ok := m.fetcher.(codex.SessionQueueClient) + text := strings.TrimSpace(q.input.Value()) + if !ok || q.busy || !q.deleting && text == "" { + return m, nil, true + } + g := m.monitorDashboardLayout() + if g.contentWidth < 40 || g.meterHeight < 7 { + q.notice = i18n.Text("Enlarge the terminal before confirming.") + return m, nil, true + } + q.busy = true + id, item, remove := q.session, q.item, q.deleting + return m, func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), 6*time.Second) + defer cancel() + return monitorQueueChanged{c.ChangeSessionQueue(ctx, id, item, text, remove)} + }, true +} + +func (m Model) renderQueueEditor() string { + q := m.monitorQueue + colors := paletteFor(m.theme) + g := m.monitorDashboardLayout() + w, h := g.contentWidth, g.meterHeight + rows := min(max(q.composerRows, 3), max(h-5, 1)) + _, _, editorY := monitorContextBodyLayout(h, rows) + q.input.configure(w, rows+6) + title := i18n.Text("EDIT QUEUED MESSAGE") + if q.deleting { + title = i18n.Text("DELETE QUEUED MESSAGE") + } + identity := shortSessionID(q.session) + if s, ok := m.contextDetailSession(); ok { + identity = monitorSessionIdentity(s) + } + lines := make([]string, max(h-2, 1)) + lines[0] = colors.header().Render(title + " // " + identity) + if len(lines) > 1 { + lines[1] = i18n.Text("Codex may start this message while you review it. Esc: cancel.") + } + body := q.input.View(colors) + if q.deleting { + body = ansi.Hardwrap(q.item.Text, max(w-4, 1), true) + } + for i, line := range strings.Split(body, "\n") { + y := editorY - 1 + i + if y >= len(lines)-2 { + break + } + if y >= 0 { + lines[y] = line + } + } + if len(lines) > 1 { + lines[len(lines)-2] = q.notice + } + buttons := "" + for _, b := range m.queueEditorButtons() { + buttons += strings.Repeat(" ", max(b.x-4-ansi.StringWidth(buttons), 0)) + style := colors.label() + if !b.enabled { + style = colors.dimmed() + } else if q.hover == b.key || q.editorFocus == 1 && b.key == "save" || q.editorFocus == 2 && b.key == "cancel" { + style = style.Foreground(colors.background).Background(colors.primary) + } + buttons += style.Render(b.text) + } + lines[len(lines)-1] = buttons + for i := range lines { + lines[i] = ansi.Truncate(lines[i], max(w-4, 1), "…") + } + return frameSized(w, max(h-2, 1), m.monitorDetailTitle(w, colors), strings.Join(lines, "\n"), colors.primary, colors) +} + +func (m Model) queueEditorButtons() []triggerButton { + g := m.monitorDashboardLayout() + if g.contentWidth < 40 || g.meterHeight < 7 { + return nil + } + q := m.monitorQueue + label := i18n.Text("[ SAVE CHANGES ]") + if q.deleting { + label = i18n.Text("[ CONFIRM DELETE ]") + } + if q.busy { + label = i18n.Text("[ WAIT… ]") + } + cancel := i18n.Text("[ CANCEL ]") + label = ansi.Truncate(label, max(g.contentWidth-6-ansi.StringWidth(cancel), 1), "…") + y := g.meterY + g.meterHeight - 2 + return []triggerButton{ + {text: label, key: "save", x: 4, y: y, enabled: !q.busy && (q.deleting || strings.TrimSpace(q.input.Value()) != "")}, + {text: cancel, key: "cancel", x: 4 + ansi.StringWidth(label) + 2, y: y, enabled: !q.busy}, + } +} diff --git a/internal/ui/monitor_queue_style_test.go b/internal/ui/monitor_queue_style_test.go new file mode 100644 index 0000000..5dfb462 --- /dev/null +++ b/internal/ui/monitor_queue_style_test.go @@ -0,0 +1,58 @@ +package ui + +import ( + "image" + "strings" + "testing" + + uv "github.com/charmbracelet/ultraviolet" + "github.com/charmbracelet/x/ansi" +) + +func TestQueuePanelThemeAndGeometry(t *testing.T) { + m, _ := queueTestModel(false) + for theme := themeID(0); theme < themeCount; theme++ { + colors := paletteFor(theme) + for _, width := range []int{34, 60, 120} { + m.monitorQueue.focused = true + m.monitorQueue.offset = 0 + m.monitorContextHover = "" + output := m.renderMonitorQueue(width, 3, colors) + lines := strings.Split(ansi.Strip(output), "\n") + if len(lines) != 3 || !strings.HasPrefix(lines[0], "─ FOLLOW-UPS") || !strings.HasPrefix(lines[1], "› QUEUED") { + t.Fatal(lines) + } + for _, line := range lines { + if ansi.StringWidth(line) != width-4 { + t.Fatal("panel width changed", width, line) + } + } + cells := uv.NewScreenBuffer(width-4, 3) + uv.NewStyledString(output).Draw(&cells, image.Rect(0, 0, width-4, 3)) + for y := 0; y < 3; y++ { + tint := monitorTintBackground(colors, 6) + if y == 1 { + tint = monitorTintBackground(colors, 18) + } + for x := 0; x < width-4; x++ { + cell := cells.CellAt(x, y) + if cell == nil || cell.Style.Bg == nil { + t.Fatalf("untinted cell %d,%d", x, y) + } + r, g, b, a := cell.Style.Bg.RGBA() + wr, wg, wb, wa := tint.RGBA() + if r != wr || g != wg || b != wb || a != wa { + t.Fatalf("wrong background theme %d at %d,%d", theme, x, y) + } + } + } + entry := m.followupEntries()[0] + buttons := followupButtons(entry, width) + for _, button := range buttons { + if got := ansi.Cut(lines[1], button.x, button.x+ansi.StringWidth(button.text)); got != button.text { + t.Fatalf("button moved: %q vs %q", got, button.text) + } + } + } + } +} diff --git a/internal/ui/monitor_queue_test.go b/internal/ui/monitor_queue_test.go new file mode 100644 index 0000000..57a14b5 --- /dev/null +++ b/internal/ui/monitor_queue_test.go @@ -0,0 +1,410 @@ +package ui + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/x/ansi" + "github.com/merefield/codexometer/internal/codex" + "github.com/merefield/codexometer/internal/schedule" +) + +type queueTestClient struct { + *turnTestClient + items []codex.SessionQueuedMessage + revision uint64 + err error + changed, thread, text string + removed bool +} + +func (c *queueTestClient) SessionQueue(context.Context, string) ([]codex.SessionQueuedMessage, error) { + return c.items, c.err +} +func (c *queueTestClient) SessionQueueRevision() uint64 { return c.revision } +func (c *queueTestClient) ChangeSessionQueue(_ context.Context, thread string, item codex.SessionQueuedMessage, text string, remove bool) error { + c.thread = thread + c.changed = item.ID + c.text = text + c.removed = remove + return c.err +} +func queueTestModel(inline bool) (Model, *queueTestClient) { + m, c := turnTestModel() + if inline { + m, c = inlineTurnTestModel() + } + m.width, m.height = 160, 55 + client := &queueTestClient{turnTestClient: c, items: []codex.SessionQueuedMessage{{ID: "one", Text: "Check the tests", Token: "token-one", Editable: true}, {ID: "two", Text: "Update documentation", Token: "token-two", Editable: true}}} + m.fetcher = client + cmd := m.pollMonitorQueue() + m, _, _ = m.updateMonitorQueue(cmd()) + return m, client +} + +func TestQueuedSendShowsReceiptAndReconcilesImmediately(t *testing.T) { + m, c := queueTestModel(false) + m.monitorQueue.items = nil + m.monitorQueue.loading = true // an older empty read is still in flight + oldRequest := m.monitorQueue.request + m.monitorPrompt.input.SetValue("Next task") + m, send, _ := m.submitMonitorTurn("queue") + m, refresh, _ := m.updateMonitorPrompt(send()) + if refresh == nil || !strings.Contains(ansi.Strip(m.render()), "ACCEPTED // CHECKING QUEUE") || !strings.Contains(ansi.Strip(m.render()), "Next task") { + t.Fatal("successful queue submission not immediately visible/refreshed") + } + entries := m.followupEntries() + if len(entries) != 1 || entries[0].editable || entries[0].removable || m.followupAction(0, "delete") != nil { + t.Fatal("receipt allowed mutation") + } + m, _, _ = m.updateMonitorQueue(monitorQueueResult{session: m.monitorQueue.session, request: oldRequest}) + if len(m.monitorQueue.submitted) != 1 { + t.Fatal("pre-send read erased receipt") + } + c.items = nil // already dispatched by the time the post-send list arrives + m, _, _ = m.updateMonitorQueue(refresh()) + if len(m.followupEntries()) != 0 { + t.Fatal("dispatched message left a phantom queue entry") + } +} + +func TestEmptyQueueFailureAndCrowdedRowRemainVisible(t *testing.T) { + m, _ := queueTestModel(true) + m.monitorSessionData[0].preview.Text = strings.Repeat("Long context\n", 100) + if !strings.Contains(ansi.Strip(m.render()), "FOLLOW-UPS // 2") { + t.Fatal("long context hid queue") + } + m.monitorQueue.items = nil + m, _, _ = m.updateMonitorQueue(monitorQueueResult{session: m.monitorQueue.session, request: m.monitorQueue.request, err: errors.New("offline")}) + if !strings.Contains(ansi.Strip(m.render()), "FOLLOW-UPS // UNAVAILABLE") { + t.Fatal("initial queue failure looked empty") + } +} + +func TestComposerDraftSurvivesDetailNavigation(t *testing.T) { + for _, working := range []bool{false, true} { + m, _ := promptTestModel() + if working { + m, _ = turnTestModel() + } else { + m.focusMonitorPrompt() + } + const draft = "Keep this unsent draft" + m.monitorPrompt.input.SetValue(draft) + for _, mode := range []int{contextWide, contextFull, contextWide, contextFull} { + m.setRowContext("root-one", mode) + if m.monitorPrompt.input.Value() != draft || m.monitorPrompt.input.Focused() { + t.Fatal("navigation lost draft or retained typing focus") + } + if !strings.Contains(ansi.Strip(m.renderMonitorPrompt(100, 30, paletteFor(m.theme))), draft) { + t.Fatal("unfocused draft replaced by placeholder") + } + m.focusMonitorPrompt() + if m.monitorPrompt.input.Value() != draft { + t.Fatal("refocusing lost draft") + } + } + m.setRowContext("another-session", contextFull) + if m.monitorPrompt.input.Value() != "" { + t.Fatal("draft transferred to another session") + } + } +} + +func TestComposerDraftsAreSessionScopedAndMemoryOnly(t *testing.T) { + m, c := promptTestModel() + other := m.monitorSessionData[0] + other.id = "root-two" + m.monitorSessionData = append(m.monitorSessionData, other) + m.focusMonitorPrompt() + m.monitorPrompt.input.SetValue("First session draft") + m.setRowContext("root-two", contextFull) + c.offer.ThreadID, c.offer.Token = "root-two", "second-capability" + m.focusMonitorPrompt() + if m.monitorPrompt.input.Value() != "" { + t.Fatal("draft leaked to second session") + } + m.monitorPrompt.input.SetValue("Second session draft") + c.offer.ThreadID, c.offer.Token = "root-one", "fresh-first-capability" + m.setRowContext("root-one", contextFull) + if m.monitorPrompt.input.Value() != "First session draft" || m.monitorPrompt.input.Focused() || m.monitorPrompt.offer.Token != c.offer.Token { + t.Fatal("first draft not restored with fresh capability and no focus") + } + c.offer.ThreadID, c.offer.Token = "root-two", "fresh-second-capability" + m.setRowContext("root-two", contextFull) + if m.monitorPrompt.input.Value() != "Second session draft" { + t.Fatal("second draft not restored") + } + m.monitorPrompt.input.Reset() // clear/send must not resurrect previous content + c.offer.ThreadID, c.offer.Token = "root-one", "first-again" + m.setRowContext("root-one", contextFull) + c.offer.ThreadID, c.offer.Token = "root-two", "second-again" + m.setRowContext("root-two", contextFull) + m.focusMonitorPrompt() + if m.monitorPrompt.input.Value() != "" { + t.Fatal("cleared draft resurrected") + } + fresh, _ := promptTestModel() + if len(fresh.monitorDrafts) != 0 { + t.Fatal("draft survived new application model") + } +} + +func TestComposerDoesNotStoreInFlightOrQuestionDrafts(t *testing.T) { + for _, kind := range []string{"busy", "question", "secret"} { + m, _ := promptTestModel() + m.focusMonitorPrompt() + m.monitorPrompt.input.SetValue("Do not retain this") + switch kind { + case "busy": + m.monitorPrompt.busy = true + case "question": + m.monitorPrompt.offer.Questions = []codex.PromptQuestion{{}} + case "secret": + m.monitorPrompt.input.secret = true + } + m.setRowContext("another", contextFull) + if len(m.monitorDrafts) != 0 { + t.Fatalf("retained %s draft", kind) + } + } +} + +func TestNativeQueuePlacementAndClickSurfaces(t *testing.T) { + for _, inline := range []bool{false, true} { + m, _ := queueTestModel(inline) + lines := strings.Split(ansi.Strip(m.render()), "\n") + queueY, composerY := -1, -1 + for y, line := range lines { + if strings.Contains(line, "FOLLOW-UPS // 2") { + queueY = y + } + if strings.Contains(line, "WORKING") && strings.Contains(line, "FOLLOW-UP") { + composerY = y + } + for label, action := range map[string]string{"[EDIT]": "queue:edit:0", "[×]": "queue:delete:0"} { + if x := strings.Index(line, label); x >= 0 && strings.Contains(line, "Check the tests") { + x = ansi.StringWidth(line[:x]) + for i := 0; i < ansi.StringWidth(label); i++ { + if got := m.monitorContextAt(x+i, y); got != action { + t.Fatalf("inline %v %s at %d,%d = %q", inline, label, x+i, y, got) + } + } + } + } + } + if queueY < 0 || composerY <= queueY { + t.Fatalf("queue not above composer: inline %v", inline) + } + if inline && !strings.Contains(lines[queueY], "1–1") { + t.Fatal("wide row didn't collapse queue") + } + // Composer remains anchored at its existing position. + w, h := m.monitorPromptSize() + rows := m.monitorPromptRows(w, h) + if rows != 3 && !m.monitorPrompt.input.Focused() { + t.Fatal("queue changed composer size") + } + } +} + +func TestNativeQueueSurvivesApprovalAndOtherClientChanges(t *testing.T) { + m, c := queueTestModel(false) + m.monitorPrompt.input.Blur() + m.monitorSessionData[0].preview.Kind = codex.SessionContextApproval + m.monitorSessionData[0].preview.Text = "Approve this command" + if !strings.Contains(ansi.Strip(m.render()), "FOLLOW-UPS // 2") { + t.Fatal("approval hid queue") + } + if m.monitorPromptOffer().Token != "" { + t.Fatal("approval exposed ordinary composer") + } + c.items = c.items[:1] + c.revision++ + cmd := m.pollMonitorQueue() + if cmd == nil { + t.Fatal("queue notification did not invalidate refresh delay") + } + m, _, _ = m.updateMonitorQueue(cmd()) + if len(m.monitorQueue.items) != 1 { + t.Fatal("external queue change not reflected") + } + c.err = errors.New("offline") + c.revision++ + cmd = m.pollMonitorQueue() + m, _, _ = m.updateMonitorQueue(cmd()) + if !m.monitorQueue.stale || len(m.monitorQueue.items) != 1 { + t.Fatal("failed refresh erased pending context") + } + m.openQueueItem(0, false) + if m.monitorQueue.open { + t.Fatal("stale queue permitted edits") + } +} + +func TestNativeQueueEditDeleteAndIsolation(t *testing.T) { + for _, remove := range []bool{false, true} { + m, c := queueTestModel(false) + m.openQueueItem(0, remove) + if !m.monitorQueue.open || m.monitorPrompt.input.Focused() { + t.Fatal("queue editor did not take exclusive focus") + } + m.monitorQueue.input.SetValue("New queue text") + m, cmd, _ := m.updateMonitorQueue(tea.KeyPressMsg(tea.Key{Code: tea.KeyEnter})) + if cmd == nil || c.changed != "" { + t.Fatal("mutation executed before command") + } + m, _, _ = m.updateMonitorQueue(cmd()) + if c.changed != "one" || c.thread != "root-one" || c.removed != remove || m.monitorQueue.open { + t.Fatal("wrong mutation target") + } + } + m, _ := queueTestModel(false) + m.monitorSelectedID = "elsewhere" + m.monitorContextDetail = "elsewhere" + m.openQueueItem(0, true) + if m.monitorQueue.open { + t.Fatal("old selection could delete queue") + } +} + +func TestNativeQueueFocusScrollAndEmpty(t *testing.T) { + m, _ := queueTestModel(false) + m, _, _ = m.updateMonitorQueue(tea.KeyPressMsg(tea.Key{Code: 'q', Mod: tea.ModAlt})) + if !m.monitorQueue.focused || m.monitorPrompt.input.Focused() { + t.Fatal("queue keyboard focus missing") + } + m, _, _ = m.updateMonitorQueue(tea.KeyPressMsg(tea.Key{Code: tea.KeyDown})) + m, _, _ = m.updateMonitorQueue(tea.KeyPressMsg(tea.Key{Code: tea.KeyEnter})) + if !m.monitorQueue.open || m.monitorQueue.item.ID != "two" { + t.Fatal("keyboard selection targeted wrong item") + } + m, _, _ = m.updateMonitorQueue(tea.KeyPressMsg(tea.Key{Code: tea.KeyEscape})) + if m.monitorQueue.open { + t.Fatal("escape did not cancel") + } + m.monitorQueue.items = nil + if m.monitorQueueRows(100, 40, 3) != 0 { + t.Fatal("empty queue reserved space") + } + m.monitorQueue.next = time.Now().Add(time.Hour) +} + +func TestUnifiedFollowupsPreserveBackendAndScheduledControls(t *testing.T) { + m, c := queueTestModel(false) + m.scheduleUI.queue = schedule.New() + if err := m.scheduleUI.queue.Save(schedule.Job{Session: "root-one", Text: "Scheduled task", Trigger: "quota"}, "a", time.Now()); err != nil { + t.Fatal(err) + } + entries := m.followupEntries() + if len(entries) != 3 || entries[0].label != "QUEUED" || entries[1].label != "QUEUED" || entries[2].label != "SCHEDULED · AFTER QUOTA REFRESH" { + t.Fatal(entries) + } + view := ansi.Strip(m.renderMonitorQueue(120, 4, paletteFor(m.theme))) + if strings.Count(view, "FOLLOW-UPS") != 1 || !strings.Contains(view, "[NOW]") || strings.Count(view, "Scheduled task") != 1 { + t.Fatal(view) + } + m.followupAction(2, "edit") + if !m.scheduleUI.open || m.scheduleUI.input.Value() != "Scheduled task" || m.monitorQueue.open || c.changed != "" { + t.Fatal("scheduled edit used native queue") + } + m.scheduleUI.open = false + m.followupAction(2, "delete") + if m.hasSchedule("root-one") || len(m.monitorQueue.items) != 2 || c.changed != "" { + t.Fatal("scheduled deletion affected native queue") + } + m.followupAction(0, "edit") + if !m.monitorQueue.open || m.scheduleUI.open || m.monitorQueue.item.ID != "one" { + t.Fatal("native edit used scheduler") + } +} + +func TestUnifiedScheduledButtonsMatchRenderedCells(t *testing.T) { + for _, inline := range []bool{false, true} { + for _, width := range []int{70, 160} { + m, _ := scheduledTestModel(t) + m.width, m.height = width, 55 + if inline { + m.monitorSessionData = m.monitorSessionData[:1] + m.setRowContext("root-one", contextWide) + } + view := strings.Split(ansi.Strip(m.render()), "\n") + hits := 0 + for y, line := range view { + if !strings.Contains(line, "[NOW]") { + continue + } + for label, action := range map[string]string{"[NOW]": "queue:now:0", "[EDIT]": "queue:edit:0", "[×]": "queue:delete:0"} { + x := strings.Index(line, label) + if x < 0 { + t.Fatal("missing button", label) + } + x = ansi.StringWidth(line[:x]) + for dx := 0; dx < ansi.StringWidth(label); dx++ { + if got := m.monitorContextAt(x+dx, y); got != action { + t.Fatalf("inline %v width %d %s got %q at %d,%d", inline, width, label, got, x+dx, y) + } + } + n, _ := m.Update(tea.MouseMotionMsg{X: x, Y: y}) + if n.(Model).monitorContextHover != action { + t.Fatal("hover mismatch") + } + hits++ + } + } + if hits != 3 { + t.Fatal("scheduled controls missing", inline, width) + } + } + } +} + +func TestQueueEditorButtonsAndKeyboardFocus(t *testing.T) { + for _, remove := range []bool{false, true} { + m, c := queueTestModel(false) + m.openQueueItem(0, remove) + lines := strings.Split(ansi.Strip(m.render()), "\n") + for _, b := range m.queueEditorButtons() { + if b.y >= len(lines) || !strings.Contains(lines[b.y], b.text) { + t.Fatal("button not on clickable row", b.y, b.text, lines) + } + pos := strings.Index(lines[b.y], b.text) + if pos < 0 || ansi.StringWidth(lines[b.y][:pos]) != b.x { + t.Fatal("button x mismatch", b) + } + n, _, _ := m.updateMonitorQueue(tea.MouseMotionMsg{X: b.x, Y: b.y}) + if n.monitorQueue.hover != b.key { + t.Fatal("button hover missing") + } + } + // Letter hotkeys remain text in an edit; no dashboard/queue action leaks. + if !remove { + m, _, _ = m.updateMonitorQueue(tea.KeyPressMsg(tea.Key{Code: 'x', Text: "x"})) + if c.changed != "" || m.monitorQueue.deleting { + t.Fatal("typing x deleted a message") + } + } + } +} + +func TestFollowupSelectionSurvivesQueueRefresh(t *testing.T) { + m, c := queueTestModel(false) + m.scheduleUI.queue = schedule.New() + if err := m.scheduleUI.queue.Save(schedule.Job{Session: "root-one", Text: "Later", Trigger: "quota"}, "a", time.Now()); err != nil { + t.Fatal(err) + } + m.monitorQueue.focused = true + m.monitorQueue.focusSession = "root-one" + m.monitorQueue.offset = 2 + c.items = c.items[:1] + c.revision++ + cmd := m.pollMonitorQueue() + m, _, _ = m.updateMonitorQueue(cmd()) + if !m.monitorQueue.focused || m.monitorQueue.offset != 1 || m.followupEntries()[1].trigger == nil { + t.Fatal("refresh moved selection to different follow-up") + } +} diff --git a/internal/ui/monitor_schedule.go b/internal/ui/monitor_schedule.go new file mode 100644 index 0000000..0e3a33c --- /dev/null +++ b/internal/ui/monitor_schedule.go @@ -0,0 +1,646 @@ +package ui + +import ( + "context" + "fmt" + "maps" + "path/filepath" + "strconv" + "strings" + "time" + + tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/x/ansi" + "github.com/merefield/codexometer/internal/codex" + "github.com/merefield/codexometer/internal/i18n" + "github.com/merefield/codexometer/internal/schedule" +) + +type scheduleUI struct { + queue *schedule.Queue + open, polling bool + session, id, notice string + input monitorEditor + mode, focus, hours, minutes int + date time.Time + confirmID, confirmToken string + confirmUntil time.Time + numeric string + scroll, composerRows int + toggleHover bool + hover string + drafts map[string]scheduleDraft +} + +type scheduleDraft struct { + id, text string + mode, hours, minutes int + date time.Time +} +type scheduleDone struct{ err error } + +// A scheduled follow-up replaces the invitation to reply, not live work or warnings. +func (m Model) sessionTriggerStatus(s monitorSession) bool { + return (s.attention == codex.SessionAttentionComplete || s.attention == codex.SessionAttentionNone) && !s.working && m.hasSchedule(s.id) +} + +func (m Model) hasSchedule(id string) bool { + if m.scheduleUI.queue == nil { + return false + } + for _, j := range m.scheduleUI.queue.List(id) { + if j.Status != "sent" { + return true + } + } + return false +} + +func (m *Model) openSchedule() tea.Cmd { + if m.meterView != viewMonitor || m.monitorContextDetail == "" { + return nil + } + id := m.monitorContextDetail + o := m.monitorPromptOffer() + if !m.hasSchedule(id) && (o.Token == "" || o.TurnID != "" || o.ThreadID != id || len(o.Questions) > 0) { + return nil + } + q := m.scheduleUI.queue + if q == nil { + q = schedule.New() + } + p := scheduleUI{queue: q, polling: m.scheduleUI.polling, drafts: m.scheduleUI.drafts, open: true, session: id, input: newMonitorEditor(), hours: 1, date: time.Now().Add(time.Hour).Truncate(time.Minute)} + g := m.monitorDashboardLayout() + p.composerRows = max(3, m.monitorPromptRows(g.contentWidth, g.meterHeight)) + if m.monitorPrompt.session == id { + p.input = m.monitorPrompt.input + } + for _, j := range q.List(id) { + if j.Status == "sent" { + continue + } + p.id = j.ID + p.input.SetValue(j.Text) + p.notice = i18n.Text("Status: ") + i18n.Text(j.Status) + if j.Trigger == "quota" { + p.mode = 0 + } else { + p.mode = 2 + p.date = j.At.Local() + } + break + } + if p.id == "" && schedule.QuotaReady(m.snapshot, time.Now()) { + p.mode = 1 + } + if draft, ok := p.drafts[id]; ok && draft.id == p.id { + p.mode, p.hours, p.minutes, p.date = draft.mode, draft.hours, draft.minutes, draft.date + p.input.SetValue(draft.text) + } + p.drafts = maps.Clone(p.drafts) + delete(p.drafts, id) + m.scheduleUI = p + m.monitorPrompt.input.Blur() + return m.scheduleUI.input.Focus() +} + +func (m *Model) dispatchSchedules() tea.Cmd { + q := m.scheduleUI.queue + client, ok := m.fetcher.(codex.SessionPromptClient) + if q == nil || !ok || m.scheduleUI.polling || m.err != nil || m.monitorError != "" || !schedule.QuotaReady(m.snapshot, time.Now()) { + return nil + } + // Do not compete with a draft, manual confirmation or profile transition. + if m.scheduleUI.open || (m.scheduleUI.confirmID != "" && time.Now().Before(m.scheduleUI.confirmUntil)) || m.monitorPrompt.input.Focused() || m.monitorPrompt.busy || m.quota.busySession != "" { + return nil + } + ready := map[string]bool{} + for _, s := range m.monitorSessionData { + _, pending := m.quotaSessionCandidate(s) + if !pending && s.preview.Kind != codex.SessionContextApproval && s.preview.Kind != codex.SessionContextQuestion { + ready[s.id] = true + } + } + account := m.snapshot.AccountFingerprint + snapshot := m.snapshot + m.scheduleUI.polling = true + return func() tea.Msg { + for _, j := range q.List("") { + if !ready[j.Session] || j.Status != "pending" || !schedule.Covers(j, snapshot) || !schedule.Fresh(snapshot, time.Now()) { + continue + } + o := client.SessionPrompt(j.Session) + if o.Token == "" || o.ThreadID != j.Session || len(o.Questions) > 0 { + continue + } + ctx, cancel := context.WithTimeout(context.Background(), 6*time.Second) + _ = q.Dispatch(ctx, j.ID, account, true, time.Now(), func(ctx context.Context, j schedule.Job) error { + return client.SendSessionPrompt(ctx, o.Token, []string{j.Text}) + }) + cancel() + } + return scheduleDone{} + } +} + +func (m Model) updateSchedule(msg tea.Msg) (Model, tea.Cmd, bool) { + if done, ok := msg.(scheduleDone); ok { + m.scheduleUI.polling = false + if done.err != nil { + m.scheduleUI.notice = i18n.Text("Send unconfirmed; check Codex before retrying.") + } + return m, nil, true + } + if !m.scheduleUI.open { + switch msg.(type) { + case tea.KeyPressMsg, tea.MouseClickMsg, tea.MouseMotionMsg: + default: + return m, nil, false + } + } + if motion, ok := msg.(tea.MouseMotionMsg); ok { + m.scheduleUI.toggleHover = m.scheduleToggleAt(motion.X, motion.Y) + if m.scheduleUI.open { + m.scheduleUI.hover = m.scheduleControlAt(motion.X, motion.Y).key + m.hoveredButton = m.footerButtonAt(motion.X, motion.Y) + if m.editorNavigationAt(motion.X, motion.Y) { + return m, nil, false + } + } + return m, nil, m.scheduleUI.open + } + if click, ok := msg.(tea.MouseClickMsg); ok && click.Button == tea.MouseLeft && m.scheduleToggleAt(click.X, click.Y) { + if m.scheduleUI.open { + m.closeSchedule() + return m, nil, true + } + cmd := m.openSchedule() + return m, cmd, true + } + key, isKey := msg.(tea.KeyPressMsg) + if click, ok := msg.(tea.MouseClickMsg); ok && click.Button == tea.MouseLeft && m.scheduleUI.open { + if m.editorNavigationAt(click.X, click.Y) { + p := &m.scheduleUI + p.drafts = maps.Clone(p.drafts) + if p.drafts == nil { + p.drafts = map[string]scheduleDraft{} + } + p.drafts[p.session] = scheduleDraft{id: p.id, text: p.input.Value(), mode: p.mode, hours: p.hours, minutes: p.minutes, date: p.date} + m.closeSchedule() + return m, nil, false + } + if button := m.footerButtonAt(click.X, click.Y); button != footerButtonNone { + next, cmd := m.pressFooterButton(button) + return next.(Model), cmd, true + } + } + if m.scheduleUI.open && isKey && m.scheduleUI.focus != 0 && (key.String() == "t" || key.String() == "r") { + button := footerButtonTheme + if key.String() == "r" { + button = footerButtonRefresh + } + next, cmd := m.pressFooterButton(button) + return next.(Model), cmd, true + } + if m.scheduleUI.open && isKey && (key.String() == "ctrl+c" || key.String() == "q" && m.scheduleUI.focus != 0) { + return m, tea.Quit, true + } + if !m.scheduleUI.open { + if click, ok := msg.(tea.MouseClickMsg); ok && click.Button == tea.MouseLeft { + if id, ok := strings.CutPrefix(m.monitorContextAt(click.X, click.Y), "attention-trigger:"); ok { + delete(m.monitorDismissed, id) + if index := m.monitorSessionIndex(id); index >= 0 { + m.monitorSessionData[index].displayed = true + } + m.monitorSessions = m.visibleMonitorSessionCount() + m.setRowContext(id, contextFull) + row := m.monitorContextRows[id] + row.review = "context" + m.monitorContextRows[id] = row + return m, nil, true + } + } + if next, cmd, handled := m.schedulePanelKey(msg); handled { + return next, cmd, true + } + if isKey && key.String() == "ctrl+s" && m.monitorContextDetail != "" { + cmd := m.openSchedule() + return m, cmd, true + } + return m, nil, false + } + p := &m.scheduleUI + if isKey && p.focus < 5 { + p.notice = "" + } + if size, ok := msg.(tea.WindowSizeMsg); ok { + m.width = size.Width + m.height = size.Height + m.revealScheduleFocus() + return m, nil, true + } + if isKey && (key.String() == "esc" || key.String() == "ctrl+s") { + m.closeSchedule() + return m, nil, true + } + g := m.monitorDashboardLayout() + p.input.configure(g.contentWidth, m.schedulePaneLayout(g.contentWidth, g.meterHeight).composerRows+6) + if wheel, ok := msg.(tea.MouseWheelMsg); ok { + layout := m.schedulePaneLayout(g.contentWidth, g.meterHeight) + if wheel.Y >= g.meterY+1 && wheel.Y < g.meterY+1+layout.textRows { + delta := 3 + if wheel.Button == tea.MouseWheelUp { + delta = -3 + } + p.scroll = min(max(layout.scroll+delta, 0), max(len(layout.indices)-layout.textRows, 0)) + } + return m, nil, true + } + if click, ok := msg.(tea.MouseClickMsg); ok { + if click.Button != tea.MouseLeft { + return m, nil, true + } + if click.Button == tea.MouseLeft { + button := m.footerButtonAt(click.X, click.Y) + if button == footerButtonTheme || button == footerButtonRefresh || button == footerButtonQuit { + next, cmd := m.pressFooterButton(button) + return next.(Model), cmd, true + } + } + layout := m.schedulePaneLayout(g.contentWidth, g.meterHeight) + localY := click.Y - g.meterY + click.X -= 2 // dashboard's outer left padding + if click.X < 2 || click.X >= g.contentWidth-2 { + return m, nil, true + } + if localY >= layout.composerY && localY < layout.composerY+layout.composerRows { + p.focus = 0 + return m, p.input.Focus(), true + } + index := localY - 1 + layout.scroll + if localY < 1 || localY >= 1+layout.textRows || index >= len(layout.indices) { + return m, nil, true + } + hit := m.scheduleControlAt(click.X+2, click.Y) + p.numeric = "" + if hit.key != "" { + p.focus = hit.focus + switch { + case strings.HasPrefix(hit.key, "mode:"): + p.mode = hit.value + case strings.HasPrefix(hit.key, "day:"): + p.date = time.Date(p.date.Year(), p.date.Month(), hit.value, p.date.Hour(), p.date.Minute(), 0, 0, p.date.Location()) + case hit.focus == 5 || hit.focus == 6: + isKey = true + key = tea.KeyPressMsg{Code: tea.KeyEnter} + } + } + p.input.Blur() + if !isKey { + m.revealScheduleFocus() + return m, nil, true + } + } + if isKey { + if p.focus >= 2 && p.focus <= 4 && !(p.mode == 2 && p.focus == 2) && p.mode != 0 && (len(key.String()) == 1 && key.String() >= "0" && key.String() <= "9" || key.String() == "backspace") { + if key.String() == "backspace" { + if len(p.numeric) > 0 { + p.numeric = p.numeric[:len(p.numeric)-1] + } + } else { + p.numeric += key.String() + } + n, _ := strconv.Atoi(p.numeric) + switch { + case p.mode == 1 && p.focus == 2: + p.hours = min(n, 8760) + case p.mode == 1 && p.focus == 3: + p.minutes = min(n, 59) + case p.mode == 2 && p.focus == 3: + p.date = time.Date(p.date.Year(), p.date.Month(), p.date.Day(), min(n, 23), p.date.Minute(), 0, 0, p.date.Location()) + case p.mode == 2 && p.focus == 4: + p.date = time.Date(p.date.Year(), p.date.Month(), p.date.Day(), p.date.Hour(), min(n, 59), 0, 0, p.date.Location()) + } + if len(p.numeric) > 4 { + p.numeric = "" + } + return m, nil, true + } + switch key.String() { + case "tab", "shift+tab": + d := 1 + if key.String() == "shift+tab" { + d = -1 + } + p.moveFocus(d) + m.revealScheduleFocus() + p.input.Blur() + if p.focus == 0 { + return m, p.input.Focus(), true + } + return m, nil, true + case "enter": + if p.focus == 5 { + if g.contentWidth < 40 || m.schedulePaneLayout(g.contentWidth, g.meterHeight).textRows < 1 { + p.notice = i18n.Text("Enlarge the terminal before confirming.") + return m, nil, true + } + now := time.Now() + if reason := p.validation(now); reason != "" { + p.notice = reason + return m, nil, true + } + o := m.monitorPromptOffer() + if o.Token == "" || o.TurnID != "" || o.ThreadID != p.session || len(o.Questions) > 0 || m.err != nil || !schedule.Fresh(m.snapshot, time.Now()) { + p.notice = i18n.Text("Session controls unavailable.") + return m, nil, true + } + j := schedule.Job{Session: p.session, Text: p.input.Value(), Trigger: "quota", Zone: time.Now().Location().String(), Windows: schedule.WindowKeys(m.snapshot)} + if p.mode == 1 { + j.Trigger = "at" + j.At = p.targetTime(now) + } + if p.mode == 2 { + j.Trigger = "at" + j.At = p.date + } + if err := p.queue.SaveIfCurrent(j, m.snapshot.AccountFingerprint, time.Now(), p.id); err != nil { + p.notice = err.Error() + } else { + p.open = false + p.toggleHover = false + p.input.Reset() + m.monitorPrompt.input.Reset() + } + } else if p.focus == 6 { + m.closeSchedule() + } else { + p.moveFocus(1) + m.revealScheduleFocus() + p.input.Blur() + if p.focus == 0 { + return m, p.input.Focus(), true + } + } + return m, nil, true + case "left", "right", "up", "down", "pgup", "pgdown": + p.numeric = "" + if p.focus != 0 { + d := 1 + if key.String() == "left" || key.String() == "down" || key.String() == "pgup" { + d = -1 + } + switch p.focus { + case 1: + p.mode = (p.mode + d + 3) % 3 + case 2: + if p.mode == 1 { + p.hours = max(0, min(8760, p.hours+d)) + } else if p.mode == 2 { + if key.String() == "pgup" || key.String() == "pgdown" { + p.date = time.Date(p.date.Year(), p.date.Month()+time.Month(d), 1, p.date.Hour(), p.date.Minute(), 0, 0, p.date.Location()) + } else { + if key.String() == "up" { + d = -7 + } + if key.String() == "down" { + d = 7 + } + p.date = p.date.AddDate(0, 0, d) + } + } + case 3: + if p.mode == 1 { + p.minutes = (p.minutes + d + 60) % 60 + } else { + p.date = p.date.Add(time.Duration(d) * time.Hour) + } + case 4: + if p.mode == 2 { + p.date = p.date.Add(time.Duration(d) * time.Minute) + } + } + m.revealScheduleFocus() + return m, nil, true + } + } + } + if p.focus == 0 { + var cmd tea.Cmd + p.input, cmd = p.input.Update(msg) + if isKey { + return m, cmd, true + } + if _, ok := msg.(tea.PasteMsg); ok { + return m, cmd, true + } + } + if isKey { + return m, nil, true + } + return m, nil, false +} + +func (m Model) scheduleFormLines() []string { + lines, _ := m.scheduleFormDocument() + return lines +} + +func (m Model) scheduleFormDocument() ([]string, []scheduleControl) { + p := m.scheduleUI + now := time.Now() + c := paletteFor(m.theme) + lines := make([]string, 24) + var controls []scheduleControl + // Rendering and interaction use these exact cell ranges, including translated labels. + control := func(row, x int, label string, focus int, key string, value int) { + label = ansi.Truncate(label, max(m.width-4-x, 0), "") + if label == "" { + return + } + style := c.label() + if strings.HasPrefix(key, "day:") { + style = style.Bold(false).Foreground(c.primary).Background(c.background) + } + if focus == 1 && p.mode == value { + style = style.Foreground(c.primary).Underline(true) + } + selected := p.focus == focus && (focus != 1 || p.mode == value) && (focus != 2 || !strings.HasPrefix(key, "day:") || p.date.Day() == value) + if selected || p.hover == key { + style = style.Underline(false).Foreground(c.background).Background(c.primary) + } else if strings.HasPrefix(key, "day:") && p.date.Day() == value { + style = style.Foreground(c.primary).Underline(true) + } + if focus == 5 && (p.validation(now) != "" || m.width < 40) { + style = c.dimmed() + } + gap := strings.Repeat(" ", max(x-ansi.StringWidth(lines[row]), 0)) + if strings.HasPrefix(key, "day:") { + gap = c.label().Bold(false).Foreground(c.primary).Background(c.background).Render(gap) + } + lines[row] += gap + style.Render(label) + controls = append(controls, scheduleControl{key: key, x: x, row: row, width: ansi.StringWidth(label), focus: focus, value: value}) + } + name := i18n.Text("Unnamed session") + for _, session := range m.monitorSessionData { + if session.id != p.session { + continue + } + if label := strings.TrimSpace(terminalLabel(session.name)); label != "" { + name = label + } else if session.workingDirectory != "" { + name = filepath.Base(terminalLabel(session.workingDirectory)) + } + break + } + prefix, suffix := i18n.Text("SCHEDULE FOLLOW-UP // "), " // "+shortSessionID(p.session) + if p.id != "" { + prefix = i18n.Text("EDIT SCHEDULED FOLLOW-UP // ") + } + lines[0] = prefix + ansi.Truncate(name, max(m.width-6-ansi.StringWidth(prefix+suffix), 1), "…") + suffix + lines[1] = i18n.Text("In memory only • closing Codexometer cancels this trigger") + lines[2] = i18n.Text("Tab/Shift+Tab: fields • Wheel: scroll • Esc: back") + // Kept in the canonical document for tests; only the composer shows this hint. + lines[6] = c.dimmed().Render(p.focusHint()) + x := 0 + modeLabels := []string{i18n.Text("AFTER QUOTA REFRESH"), i18n.Text("IN…"), i18n.Text("AT…")} + reserved := max(9, ansi.StringWidth(modeLabels[1])+4) + max(9, ansi.StringWidth(modeLabels[2])+4) + 6 + modeLabels[0] = ansi.Truncate(modeLabels[0], max(m.width-4-reserved, 3), "…") + total := 2 + for _, label := range modeLabels { + total += max(9, ansi.StringWidth(label)+4) + } + if total > m.width-4 { + for i, label := range modeLabels { + modeLabels[i] = ansi.Truncate(label, max((m.width-6)/3-4, 1), "…") + } + } + for i, label := range modeLabels { + label = "[ " + label + " ]" + control(7, x, label, 1, fmt.Sprintf("mode:%d", i), i) + x += max(9, ansi.StringWidth(label)) + 1 + } + if p.mode == 1 { + hours := i18n.Format("Hours: %d", p.hours) + control(9, 0, hours, 2, "hours", 0) + control(9, max(20, ansi.StringWidth(hours)+2), i18n.Format("Minutes: %02d", p.minutes), 3, "minutes", 0) + } + if p.mode == 2 { + month := p.date.Format("January 2006") + if i18n.Code() != "en-GB" { + month = p.date.Format("2006-01") + } + control(9, 0, month+" // PgUp/PgDn", 2, "calendar", 0) + for _, day := range strings.Fields(i18n.Text("Su Mo Tu We Th Fr Sa")) { + lines[10] += day + strings.Repeat(" ", max(4-ansi.StringWidth(day), 0)) + } + first := time.Date(p.date.Year(), p.date.Month(), 1, 0, 0, 0, 0, p.date.Location()) + for day := 1; day <= first.AddDate(0, 1, -1).Day(); day++ { + cell := int(first.Weekday()) + day - 1 + control(11+cell/7, (cell%7)*4, fmt.Sprintf("%2d", day), 2, fmt.Sprintf("day:%d", day), day) + } + for row := 11; row <= 16; row++ { + lines[row] += c.label().Bold(false).Foreground(c.primary).Background(c.background).Render(strings.Repeat(" ", max(28-ansi.StringWidth(lines[row]), 0))) + } + hour := i18n.Text("Hour: ") + p.date.Format("15") + control(18, 0, hour, 3, "hour", 0) + control(18, max(20, ansi.StringWidth(hour)+2), i18n.Text("Minute: ")+p.date.Format("04"), 4, "minute", 0) + } + row := p.actionRow() + lines[row-2] = c.header().Render(i18n.Text("Will send: ") + scheduleDate(p.targetTime(now))) + lines[row-1] = i18n.Text("Not before this time; when quota + idle allow") + if p.mode == 0 { + lines[row-2] = c.header().Render(i18n.Text("Will send: when quota is available")) + lines[row-1] = i18n.Text("and this session is idle") + } + back := i18n.Text("[ BACK ]") + confirm := ansi.Truncate(p.confirmLabel(), max(m.width-6-ansi.StringWidth(back), 1), "…") + control(row, 0, confirm, 5, "confirm", 0) + control(row, min(max(23, ansi.StringWidth(confirm)+2), max(m.width-4-ansi.StringWidth(back), 0)), back, 6, "back", 0) + notice := p.notice + if reason := p.validation(now); reason != "" { + notice = reason + } + if row == 22 { + lines[19] = notice + } else { + lines[row+1] = notice + } + for i := 1; i < len(lines); i++ { + lines[i] = ansi.Truncate(lines[i], max(m.width-4, 1), "…") + } + return lines, controls +} +func (p scheduleUI) targetTime(now time.Time) time.Time { + if p.mode == 1 { + return now.Add(time.Duration(p.hours)*time.Hour + time.Duration(p.minutes)*time.Minute) + } + return p.date +} + +func (p scheduleUI) confirmLabel() string { + if p.id != "" { + return i18n.Text("[ SAVE CHANGES ]") + } + return i18n.Text("[ CONFIRM SCHEDULE ]") +} + +func (p scheduleUI) validation(now time.Time) string { + text := strings.TrimSpace(p.input.Value()) + switch { + case text == "": + return i18n.Text("Enter a message to schedule.") + case len([]rune(text)) > 4096: + return i18n.Text("Keep the message within 4096 characters.") + case codex.SanitizeSessionContext(text) != text: + return i18n.Text("Remove terminal control characters.") + case p.mode != 0 && !p.targetTime(now).After(now): + return i18n.Text("Choose a future time (delay must exceed 0).") + case p.mode != 0 && p.targetTime(now).After(now.AddDate(1, 0, 0)): + return i18n.Text("Choose a time within one year.") + } + return "" +} + +func (p scheduleUI) focusHint() string { + switch p.focus { + case 0: + return i18n.Text("Type your message • Enter: next field") + case 1: + return i18n.Text("←/→: trigger • Enter: next • q: quit") + case 2: + if p.mode == 2 { + return i18n.Text("Arrows: date • PgUp/Dn: month • q: quit") + } + fallthrough + case 3, 4: + return i18n.Text("Digits/arrows: time • Enter: next • q: quit") + default: + return i18n.Text("Enter: activate • q: quit") + } +} + +func (p scheduleUI) actionRow() int { + if p.mode == 2 { + return 22 + } + return 13 +} + +func (p *scheduleUI) moveFocus(delta int) { + fields := []int{0, 1, 5, 6} + if p.mode == 1 { + fields = []int{0, 1, 2, 3, 5, 6} + } else if p.mode == 2 { + fields = []int{0, 1, 2, 3, 4, 5, 6} + } + index := 0 + for i, f := range fields { + if f == p.focus { + index = i + break + } + } + p.focus = fields[(index+delta+len(fields))%len(fields)] + p.numeric = "" +} diff --git a/internal/ui/monitor_schedule_localisation_test.go b/internal/ui/monitor_schedule_localisation_test.go new file mode 100644 index 0000000..f2aa475 --- /dev/null +++ b/internal/ui/monitor_schedule_localisation_test.go @@ -0,0 +1,48 @@ +package ui + +import ( + "strings" + "testing" + + "github.com/charmbracelet/x/ansi" + "github.com/merefield/codexometer/internal/codex" + "github.com/merefield/codexometer/internal/i18n" +) + +// Invoked by the locale subprocess suite as well as the ordinary UI suite. +func TestScheduleLocalisedStatusAndHint(t *testing.T) { + m, _ := scheduledTestModel(t) + s := &m.monitorSessionData[0] + s.working = false + s.attention = codex.SessionAttentionComplete + want := i18n.Text("TRIGGER SET") + if i18n.Code() != "en-GB" && want == "TRIGGER SET" { + t.Fatal("trigger status fell back to English") + } + for name, rendered := range map[string]string{ + "badge": m.renderMonitorSessionBadge(*s, 120, paletteFor(m.theme)), + "detail": m.renderMonitorContextDetail(160, 40, paletteFor(m.theme)), + } { + if !strings.Contains(ansi.Strip(rendered), want) { + t.Fatalf("%s missing translated trigger status %q", name, want) + } + } + buttons, _ := m.monitorAttentionButtons(240, 1) + found := false + for _, button := range buttons { + if button.action == "attention-trigger:"+s.id { + found = true + if !strings.Contains(button.label, want) { + t.Fatalf("pill missing translated trigger status %q: %q", want, button.label) + } + } + } + if !found { + t.Fatal("trigger pill missing") + } + m.scheduleUI.notice = "Pending trigger" + layout := m.layoutDetailControlsBase(20, 7) + if layout.kind != "notice" || layout.notice != i18n.Text("Ctrl+S: EDIT") { + t.Fatalf("compact edit hint not translated: %+v", layout) + } +} diff --git a/internal/ui/monitor_schedule_pane.go b/internal/ui/monitor_schedule_pane.go new file mode 100644 index 0000000..42fddac --- /dev/null +++ b/internal/ui/monitor_schedule_pane.go @@ -0,0 +1,131 @@ +package ui + +import ( + "strings" + + "github.com/charmbracelet/x/ansi" +) + +// Canonical form rows are shared by rendering, focus scrolling and mouse hits. +// The editor is outside this viewport, anchored at the existing composer edge. +type schedulePaneLayout struct { + indices []int + textRows, scroll, composerY, composerRows int +} + +func (m Model) schedulePaneLayout(width, height int) schedulePaneLayout { + p := m.scheduleUI + l := schedulePaneLayout{indices: []int{0, 1, 2, 7, 8}} + for row := 9; row <= min(p.actionRow()+1, 23); row++ { + l.indices = append(l.indices, row) + } + l.composerRows = min(max(p.composerRows, 3), max(height-5, 1)) + l.textRows, _, l.composerY = monitorContextBodyLayout(height, l.composerRows) + l.scroll = min(max(p.scroll, 0), max(len(l.indices)-l.textRows, 0)) + return l +} + +func (m *Model) revealScheduleFocus() { + g := m.monitorDashboardLayout() + l := m.schedulePaneLayout(g.contentWidth, g.meterHeight) + p := &m.scheduleUI + target := 7 + switch p.focus { + case 0: + return + case 2: + target = 9 + if p.mode == 2 { + // Month starts can offset the selected day into the following row. + firstWeekday := (int(p.date.Weekday()) - (p.date.Day()-1)%7 + 7) % 7 + target = 11 + (firstWeekday+p.date.Day()-1)/7 + } + case 3: + target = 9 + if p.mode == 2 { + target = 18 + } + case 4: + target = 18 + case 5, 6: + target = p.actionRow() + } + for index, row := range l.indices { + if row != target { + continue + } + p.scroll = l.scroll + if index < l.scroll { + p.scroll = index + } + if index >= l.scroll+l.textRows { + p.scroll = max(0, index-l.textRows+1) + } + break + } +} + +func (m *Model) closeSchedule() { + p := &m.scheduleUI + // Cancelling edits to an existing trigger must not overwrite an unrelated + // reply draft. A new schedule uses the same ordinary follow-up draft. + if p.id == "" { + if !m.monitorPrompt.input.ready { + m.monitorPrompt.input = newMonitorEditor() + } + m.monitorPrompt.session = p.session + m.monitorPrompt.offer = m.monitorPromptOffer() + m.monitorPrompt.input.SetValue(p.input.Value()) + m.monitorPrompt.input.Blur() + } + p.open = false + p.toggleHover = false + p.input.Blur() +} + +func (m Model) renderScheduleDetail(width, height int, colors palette) string { + l := m.schedulePaneLayout(width, height) + m.width = width + document := m.scheduleFormLines() + document[0] = colors.header().Render(ansi.Truncate(document[0], max(width-4, 1), "…")) + body := make([]string, max(height-2, 1)) + for row := 0; row < l.textRows && row+l.scroll < len(l.indices); row++ { + body[row] = document[l.indices[row+l.scroll]] + } + editor := m.scheduleUI.input + editor.configure(width, l.composerRows+6) + composer := make([]string, l.composerRows) + identity := m.scheduleUI.session + if s, ok := m.contextDetailSession(); ok { + identity = monitorSessionIdentity(s) + } + composer[0] = m.renderScheduleToggleHeader(width, identity, true, colors) + input := strings.Split(editor.View(colors), "\n") + inputEnd := l.composerRows + if l.composerRows >= 3 { + inputEnd-- + } + for row := 1; row < inputEnd; row++ { + if row-1 < len(input) { + composer[row] = input[row-1] + } else { + composer[row] = colors.label().Background(monitorComposerBackground(colors)).Width(max(width-4, 1)).Render("") + } + } + hint := m.scheduleUI.focusHint() + if m.scheduleUI.notice != "" { + hint = m.scheduleUI.notice + } + if l.composerRows >= 3 { + composer[l.composerRows-1] = colors.label().Render(ansi.Truncate(hint, max(width-4, 1), "…")) + } else if l.composerRows == 1 && len(input) > 0 { + composer[0] = input[0] + } + for row, line := range composer { + index := l.composerY - 1 + row + if index < len(body) { + body[index] = line + } + } + return frameSized(width, max(height-2, 1), m.monitorDetailTitle(width, colors), strings.Join(body, "\n"), colors.primary, colors) +} diff --git a/internal/ui/monitor_schedule_pane_test.go b/internal/ui/monitor_schedule_pane_test.go new file mode 100644 index 0000000..e0843f1 --- /dev/null +++ b/internal/ui/monitor_schedule_pane_test.go @@ -0,0 +1,111 @@ +package ui + +import ( + "strings" + "testing" + "time" + + tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/x/ansi" +) + +func TestSchedulePaneKeepsDashboardAndComposerAnchored(t *testing.T) { + for _, height := range []int{24, 40, 60} { + for _, draft := range []string{"Draft to keep", strings.Repeat("Wrapped draft ", 60)} { + m, _ := promptTestModel() + m.height = height + m.focusMonitorPrompt() + m.monitorPrompt.input.SetValue(draft) + before := strings.Split(ansi.Strip(m.render()), "\n") + find := func(lines []string, marker string) int { + for i, line := range lines { + if strings.Contains(line, marker) { + return i + } + } + return -1 + } + composer := find(before, "[ Ctrl+S:") + if composer < 0 { + t.Fatal("missing original composer") + } + m.openSchedule() + after := strings.Split(ansi.Strip(m.render()), "\n") + if find(after, "[ Ctrl+S:") != composer { + t.Fatalf("height %d composer moved: %d -> %d", height, composer, find(after, "[ Ctrl+S:")) + } + g := m.monitorDashboardLayout() + for i := 0; i < g.meterY; i++ { + if before[i] != after[i] { + t.Fatalf("dashboard changed at row %d", i) + } + } + if !strings.Contains(strings.Join(after, "\n"), "SCHEDULE FOLLOW-UP") { + t.Fatal("form missing") + } + m.scheduleUI.input.SetValue("Edited draft") + m, _, _ = m.updateSchedule(tea.KeyPressMsg{Code: tea.KeyEsc}) + if m.scheduleUI.open || m.monitorPrompt.input.Value() != "Edited draft" || m.monitorContextDetail != "root-one" { + t.Fatal("back lost draft or session") + } + } + } +} + +func TestSchedulePaneCancelKeepsExistingTriggerAndSeparateDraft(t *testing.T) { + m, _ := scheduledTestModel(t) + m.focusMonitorPrompt() + m.monitorPrompt.input.SetValue("Separate reply draft") + before := m.scheduleUI.queue.List("root-one")[0] + m.openSchedule() + m.scheduleUI.input.SetValue("Uncommitted trigger edit") + m.closeSchedule() + if m.monitorPrompt.input.Value() != "Separate reply draft" || m.scheduleUI.queue.List("root-one")[0].Text != before.Text { + t.Fatal("cancel changed draft or saved trigger") + } +} + +func TestSchedulePaneScrollAndHitTargets(t *testing.T) { + m, _ := promptTestModel() + m.height = 26 + m.openSchedule() + m.scheduleUI.mode = 2 + m.scheduleUI.date = time.Date(2026, time.October, 28, 12, 0, 0, 0, time.Local) + m.scheduleUI.focus = 2 + m.revealScheduleFocus() + g := m.monitorDashboardLayout() + l := m.schedulePaneLayout(g.contentWidth, g.meterHeight) + if l.scroll == 0 { + t.Fatal("calendar focus did not scroll into view") + } + // The selected date is Wednesday in the fifth calendar row. + m, _, _ = m.updateSchedule(scheduleFormClick(m, 2+3*4, 15)) + if m.scheduleUI.date.Day() != 28 { + t.Fatal("scrolled calendar click missed date", m.scheduleUI.date) + } + m.scheduleUI.focus = 5 + m.revealScheduleFocus() + l = m.schedulePaneLayout(g.contentWidth, g.meterHeight) + actionVisible := false + for _, row := range l.indices[l.scroll:min(l.scroll+l.textRows, len(l.indices))] { + if row == 22 { + actionVisible = true + } + } + if !actionVisible { + t.Fatal("keyboard focus left action offscreen") + } + // Clicking composer focuses it without changing the scroll/trigger. + m, _, _ = m.updateSchedule(tea.MouseClickMsg{X: 6, Y: g.meterY + l.composerY + 1, Button: tea.MouseLeft}) + if m.scheduleUI.focus != 0 || !m.scheduleUI.input.Focused() { + t.Fatal("anchored composer click missed") + } + m, _, _ = m.updateSchedule(tea.MouseWheelMsg{X: 6, Y: g.meterY + 2, Button: tea.MouseWheelUp}) + if m.scheduleUI.scroll >= l.scroll { + t.Fatal("form wheel did not scroll") + } + m, _, _ = m.updateSchedule(tea.WindowSizeMsg{Width: 100, Height: 40}) + if m.width != 100 || m.height != 40 || !m.scheduleUI.open { + t.Fatal("resize lost schedule") + } +} diff --git a/internal/ui/monitor_schedule_panel.go b/internal/ui/monitor_schedule_panel.go new file mode 100644 index 0000000..e3ddba5 --- /dev/null +++ b/internal/ui/monitor_schedule_panel.go @@ -0,0 +1,224 @@ +package ui + +import ( + "context" + "strings" + "time" + + tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/x/ansi" + "github.com/merefield/codexometer/internal/codex" + "github.com/merefield/codexometer/internal/i18n" + "github.com/merefield/codexometer/internal/schedule" +) + +func (m Model) pendingTrigger() (schedule.Job, bool) { + if m.monitorContextTarget() == "" || m.scheduleUI.queue == nil { + return schedule.Job{}, false + } + for _, j := range m.scheduleUI.queue.List(m.monitorContextTarget()) { + if j.Status != "sent" { + return j, true + } + } + return schedule.Job{}, false +} + +func (m Model) triggerReady(j schedule.Job) bool { + if !j.MatchesAccount(m.snapshot.AccountFingerprint) { + return false + } + o := m.monitorPromptOffer() + return j.Status == "pending" && m.err == nil && m.monitorError == "" && m.quota.busySession == "" && schedule.QuotaReady(m.snapshot, time.Now()) && schedule.Covers(j, m.snapshot) && o.Token != "" && o.TurnID == "" && o.ThreadID == j.Session && len(o.Questions) == 0 +} + +func (m Model) triggerSummary(j schedule.Job) string { + return m.triggerSummaryAt(j, time.Now()) +} + +func (m Model) triggerSummaryAt(j schedule.Job, now time.Time) string { + text := i18n.Text("After quota recovery") + if j.Trigger == "at" { + text = i18n.Text("Not before ") + scheduleDate(j.At.Local()) + remaining := j.At.Sub(now) + if remaining > 0 { + text += i18n.Text(" // Due in ") + remaining.Round(time.Second).String() + if j.Status == "pending" { + return text + } + } else if j.Status == "pending" { + text += i18n.Text(" // Time reached") + } + } + switch { + case j.Status != "pending": + return text + " // " + i18n.Text(j.Status) + case m.err != nil || !schedule.Fresh(m.snapshot, now): + return text + i18n.Text(" // waiting for fresh quota") + case !j.MatchesAccount(m.snapshot.AccountFingerprint): + return text + i18n.Text(" // waiting for the original account") + case !schedule.Covers(j, m.snapshot): + return text + i18n.Text(" // waiting for observed quota windows") + case !schedule.QuotaReady(m.snapshot, now): + return text + i18n.Text(" // waiting for quota") + case !m.triggerReady(j): + if i := m.monitorSessionIndex(j.Session); i >= 0 && m.monitorSessionData[i].working { + return text + i18n.Text(" // waiting for session to finish") + } + return text + i18n.Text(" // waiting for an eligible idle session") + } + return text + i18n.Text(" // ready; awaiting dispatch") +} + +type triggerButton struct { + text, key string + x, y int + enabled bool +} + +func (m Model) triggerButtons(width int) []triggerButton { + j, ok := m.pendingTrigger() + if !ok { + return nil + } + buttons := []triggerButton{{text: i18n.Text("[ Ctrl+N: SEND NOW ]"), key: "ctrl+n", enabled: m.triggerReady(j)}, {text: i18n.Text("[ Ctrl+S: EDIT ]"), key: "ctrl+s", enabled: j.Status == "pending"}, {text: i18n.Text("[ Ctrl+D: DELETE ]"), key: "ctrl+d", enabled: j.Status != "sending"}} + if m.scheduleUI.confirmID == j.ID && time.Now().Before(m.scheduleUI.confirmUntil) { + buttons = []triggerButton{{text: i18n.Text("[ Ctrl+Y: CONFIRM SEND ]"), key: "ctrl+y", enabled: m.triggerReady(j)}, {text: i18n.Text("[ Esc: CANCEL ]"), key: "esc", enabled: true}} + } + x, y := 0, 0 + for i := range buttons { + w := ansi.StringWidth(buttons[i].text) + if x > 0 && x+w > max(width-4, 1) { + x = 0 + y++ + } + buttons[i].x = x + buttons[i].y = y + x += w + 2 + } + return buttons +} +func (m Model) schedulePanelLines(width int) []string { + buttons := m.triggerButtons(width) + if len(buttons) == 0 { + return nil + } + c := paletteFor(m.theme) + lines := make([]string, buttons[len(buttons)-1].y+1) + for _, b := range buttons { + style := c.label().Foreground(c.primary) + if !b.enabled { + style = c.dimmed() + } else if m.monitorContextHover == "schedule:"+b.key { + style = style.Foreground(c.background).Background(c.primary) + } + if lines[b.y] != "" { + lines[b.y] += " " + } + lines[b.y] += style.Render(b.text) + } + if m.scheduleUI.confirmID != "" && time.Now().Before(m.scheduleUI.confirmUntil) { + lines = append(lines, i18n.Text("Send this saved prompt now? This starts Codex work.")) + } + if m.scheduleUI.notice != "" { + lines = append(lines, m.scheduleUI.notice) + } + for i := range lines { + lines[i] = ansi.Truncate(lines[i], max(width-4, 1), "…") + } + return lines +} + +// Use the same visible rectangles for hover and click, including wrapped rows. +func (m Model) scheduleButtonAt(x, y int) (triggerButton, bool) { + if m.meterView != viewMonitor || m.monitorContextDetail == "" || m.contextTargetHidden() || !m.hasSchedule(m.monitorContextDetail) { + return triggerButton{}, false + } + g := m.monitorDashboardLayout() + if m.layoutDetailControls(g.contentWidth, g.meterHeight).kind != "schedule" || x < 4 || x >= g.contentWidth { + return triggerButton{}, false + } + _, _, top := monitorContextBodyLayout(g.meterHeight, len(m.schedulePanelLines(g.contentWidth))) + for _, b := range m.triggerButtons(g.contentWidth) { + if y == g.meterY+top+b.y && x >= 4+b.x && x < 4+b.x+ansi.StringWidth(b.text) { + return b, true + } + } + return triggerButton{}, false +} + +func (m *Model) triggerAction(key string) tea.Cmd { + j, ok := m.pendingTrigger() + if !ok { + return nil + } + p := &m.scheduleUI + switch key { + case "ctrl+s": + return m.openSchedule() + case "esc": + p.confirmID = "" + case "ctrl+d": + if err := p.queue.Cancel(j.Session, j.ID); err != nil { + p.notice = err.Error() + } else { + p.confirmID = "" + p.notice = "" + } + case "ctrl+n": + if m.triggerReady(j) { + p.confirmID = j.ID + p.confirmToken = m.monitorPromptOffer().Token + p.confirmUntil = time.Now().Add(20 * time.Second) + p.notice = "" + } + case "ctrl+y": + if p.confirmID != j.ID || !time.Now().Before(p.confirmUntil) || !m.triggerReady(j) || p.confirmToken != m.monitorPromptOffer().Token { + p.confirmID = "" + return nil + } + client, ok := m.fetcher.(codex.SessionPromptClient) + if !ok { + return nil + } + token, account, q := p.confirmToken, m.snapshot.AccountFingerprint, p.queue + p.confirmID = "" + p.polling = true + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), 6*time.Second) + defer cancel() + err := q.DispatchNow(ctx, j.ID, account, time.Now(), func(ctx context.Context, j schedule.Job) error { + return client.SendSessionPrompt(ctx, token, []string{j.Text}) + }) + return scheduleDone{err: err} + } + } + return nil +} + +func (m Model) schedulePanelKey(msg tea.Msg) (Model, tea.Cmd, bool) { + if m.monitorContextDetail == "" || !m.hasSchedule(m.monitorContextDetail) { + return m, nil, false + } + if s, ok := m.contextDetailSession(); !ok || m.hasSessionProfile(s) || s.preview.Kind == codex.SessionContextApproval || s.preview.Kind == codex.SessionContextQuestion { + return m, nil, false + } + key := "" + if k, ok := msg.(tea.KeyPressMsg); ok { + key = k.String() + } + if click, ok := msg.(tea.MouseClickMsg); ok && click.Button == tea.MouseLeft { + if b, hit := m.scheduleButtonAt(click.X, click.Y); hit { + if b.enabled { + key = b.key + } + cmd := m.triggerAction(key) + return m, cmd, true + } + } + if strings.Contains("|ctrl+n|ctrl+d|ctrl+y|", "|"+key+"|") || (key == "esc" && m.scheduleUI.confirmID != "") { + cmd := m.triggerAction(key) + return m, cmd, true + } + return m, nil, false +} diff --git a/internal/ui/monitor_schedule_test.go b/internal/ui/monitor_schedule_test.go new file mode 100644 index 0000000..cd6de9c --- /dev/null +++ b/internal/ui/monitor_schedule_test.go @@ -0,0 +1,666 @@ +package ui + +import ( + tea "charm.land/bubbletea/v2" + uv "github.com/charmbracelet/ultraviolet" + "github.com/charmbracelet/x/ansi" + "github.com/merefield/codexometer/internal/codex" + "github.com/merefield/codexometer/internal/schedule" + "image" + "strings" + "testing" + "time" +) + +// Test the canonical form document separately from its scrolling placement. +func (m Model) renderScheduleForm() string { + if m.width < 48 || m.height < 24 { + return "Scheduling needs 48 columns × 24 rows. Resize or Esc to return." + } + lines := m.scheduleFormLines() + c := paletteFor(m.theme) + return frameSized(m.width, m.height-2, lines[0], strings.Join(lines[1:23], "\n"), c.primary, c) +} + +func scheduleFormClick(m Model, x, row int) tea.MouseClickMsg { + g := m.monitorDashboardLayout() + l := m.schedulePaneLayout(g.contentWidth, g.meterHeight) + for i, canonical := range l.indices { + if canonical == row { + return tea.MouseClickMsg{X: x + 2, Y: g.meterY + 1 + i - l.scroll, Button: tea.MouseLeft} + } + } + return tea.MouseClickMsg{X: x + 2, Y: g.meterY + l.composerY + 1, Button: tea.MouseLeft} +} + +func TestScheduleFormRefinements(t *testing.T) { + m, _ := scheduledTestModel(t) + m.openSchedule() + m.scheduleUI.mode, m.scheduleUI.focus = 1, 2 + m.scheduleUI.hours, m.scheduleUI.minutes = 2, 15 + now := time.Date(2026, 10, 6, 12, 0, 0, 0, time.FixedZone("BST", 3600)) + if got := m.scheduleUI.targetTime(now); !got.Equal(now.Add(135 * time.Minute)) { + t.Fatal("delay summary target differs from dispatch target") + } + lines := strings.Split(ansi.Strip(m.renderScheduleForm()), "\n") + if !strings.Contains(lines[0], "EDIT SCHEDULED FOLLOW-UP") || !strings.Contains(lines[13], "SAVE CHANGES") || !strings.Contains(lines[11], "Will send:") || !strings.Contains(lines[6], "Digits/arrows") { + t.Fatal("missing edit title, summary, action or contextual hint") + } + cells := uv.NewScreenBuffer(m.width, m.height) + uv.NewStyledString(m.renderScheduleForm()).Draw(&cells, image.Rect(0, 0, m.width, m.height)) + for x := 2 + len("Hours: 2"); x < 22; x++ { + cell := cells.CellAt(x, 9) + if cell == nil || cell.Style.Attrs&uv.AttrReverse != 0 { + t.Fatalf("hours highlight leaked into spacer at %d", x) + } + } + for _, tc := range []struct { + text string + mode, hours, minutes int + at time.Time + want string + }{ + {"", 0, 0, 0, now, "Enter a message"}, + {"Test", 1, 0, 0, now, "Choose a future"}, + {"Test", 2, 0, 0, now.Add(-time.Minute), "Choose a future"}, + {"Test", 2, 0, 0, now.AddDate(2, 0, 0), "within one year"}, + {"Test", 0, 0, 0, now, ""}, + {"Test", 1, 0, 1, now, ""}, + } { + p := m.scheduleUI + p.input.SetValue(tc.text) + p.mode, p.hours, p.minutes, p.date = tc.mode, tc.hours, tc.minutes, tc.at + if got := p.validation(now); (tc.want == "" && got != "") || (tc.want != "" && !strings.Contains(got, tc.want)) { + t.Fatalf("validation = %q, want %q", got, tc.want) + } + } + m.scheduleUI.input.SetValue("") + m.scheduleUI.focus = 5 + before := m.scheduleUI.queue.List("root-one")[0].ID + n, _, _ := m.updateSchedule(tea.KeyPressMsg{Code: tea.KeyEnter}) + if !n.scheduleUI.open || n.scheduleUI.queue.List("root-one")[0].ID != before || !strings.Contains(n.renderScheduleForm(), "Enter a message") { + t.Fatal("invalid save changed the job or omitted its explanation") + } +} + +func TestScheduleWaitingSummary(t *testing.T) { + m, _ := scheduledTestModel(t) + j := m.scheduleUI.queue.List("root-one")[0] + now := time.Now() + if got := m.triggerSummaryAt(j, now); !strings.Contains(got, "Due in") || strings.Contains(got, "waiting for") { + t.Fatal("future job described as overdue", got) + } + j.At = now.Add(-time.Minute) + m.snapshot.RateLimits.Primary.UsedPercent = 100 + if got := m.triggerSummaryAt(j, now); !strings.Contains(got, "Time reached // waiting for quota") { + t.Fatal("missing quota wait", got) + } + m.snapshot.FetchedAt = now.Add(-10 * time.Minute) + if got := m.triggerSummaryAt(j, now); !strings.Contains(got, "waiting for fresh quota") { + t.Fatal("missing fresh quota wait", got) + } +} + +func TestScheduleCalendarCellStyles(t *testing.T) { + m, _ := scheduledTestModel(t) + m.openSchedule() + m.scheduleUI.mode = 2 + // Exercise both sides of the selection, week boundaries and blank cells. + for day := 1; day <= 31; day++ { + m.scheduleUI.date = time.Date(2026, time.October, day, 12, 0, 0, 0, time.UTC) + for focus := 0; focus <= 6; focus++ { + m.scheduleUI.focus = focus + cells := uv.NewScreenBuffer(m.width, m.height) + uv.NewStyledString(m.renderScheduleForm()).Draw(&cells, image.Rect(0, 0, m.width, m.height)) + c := paletteFor(m.theme) + for row := 0; row < 6; row++ { + for col := 0; col < 7; col++ { + date := row*7 + col - int(time.Thursday) + 1 + for offset := 0; offset < 4; offset++ { + want := uv.Style{Fg: c.primary, Bg: c.background} + if date == day && offset < 2 { + if focus == 2 { + want.Fg, want.Bg = c.background, c.primary + } else { + want.Underline = uv.UnderlineSingle + } + } + cell := cells.CellAt(2+col*4+offset, 11+row) + if cell == nil || !cell.Style.Equal(&want) { + t.Fatalf("day %d focus %d row %d col %d offset %d: unexpected cell style: %+v, want %+v", day, focus, row, col, offset, cell, want) + } + } + } + } + } + } +} + +func TestScheduleTriggerSelectionDoesNotHighlightWholeGroup(t *testing.T) { + m, _ := scheduledTestModel(t) + m.openSchedule() + colors := paletteFor(m.theme) + labels := []string{"AFTER QUOTA REFRESH", "IN…", "AT…"} + for _, focus := range []int{0, 1, 2} { + m.scheduleUI.focus = focus + for _, selected := range []int{0, 1, 2, 1, 0} { + m.scheduleUI.mode = selected + row := m.scheduleFormLines()[7] + for i, name := range labels { + label := "[ " + name + " ]" + style := colors.label() + if i == selected { + style = style.Foreground(colors.primary).Underline(true) + if focus == 1 { + style = style.Underline(false).Foreground(colors.background).Background(colors.primary) + } + } + if !strings.Contains(row, style.Render(label)) { + t.Fatalf("focus %d selected %d changed button %d styling", focus, selected, i) + } + } + } + } +} + +func TestScheduleTriggerSelectionSurvivesTab(t *testing.T) { + for theme := themeHacker; theme < themeCount; theme++ { + for mode := 0; mode < 3; mode++ { + m, _ := scheduledTestModel(t) + m.theme = theme + m.openSchedule() + m.scheduleUI.mode, m.scheduleUI.focus = mode, 1 + focused := m.scheduleFormLines()[7] + m, _, _ = m.updateSchedule(tea.KeyPressMsg{Code: tea.KeyTab}) + if m.scheduleUI.mode != mode || m.scheduleUI.focus == 1 { + t.Fatal("Tab changed selection or failed to move focus") + } + unfocused := m.scheduleFormLines()[7] + if focused == unfocused || ansi.Strip(focused) != ansi.Strip(unfocused) { + t.Fatal("focus must change appearance without moving buttons") + } + cells := uv.NewScreenBuffer(m.width, m.height) + uv.NewStyledString(m.renderScheduleForm()).Draw(&cells, image.Rect(0, 0, m.width, m.height)) + // Check actual rendered cells, not just the styling helper's output. + x := 2 + for i, label := range []string{"[ AFTER QUOTA REFRESH ]", "[ IN… ] ", "[ AT… ] "} { + cell := cells.CellAt(x+2, 7) + if cell == nil || (cell.Style.Underline == uv.UnderlineSingle) != (i == mode) || cell.Style.Attrs&uv.AttrReverse != 0 { + t.Fatalf("theme %d mode %d button %d: selection lost after Tab", theme, mode, i) + } + x += ansi.StringWidth(label) + 1 + } + } + } +} + +func TestScheduleFormSaveSignpostAndCancel(t *testing.T) { + m, c := promptTestModel() + m.monitorSessionData[0].working = false + m.width = 100 + m.height = 40 + m.snapshot = codex.Snapshot{AccountFingerprint: "a", FetchedAt: time.Now(), RateLimits: codex.RateLimitSnapshot{Primary: &codex.Window{UsedPercent: 100}}} + m.openSchedule() + if !m.scheduleUI.open { + t.Fatal("form unavailable") + } + m.scheduleUI.input.SetValue("Do this later") + m.scheduleUI.focus = 5 + m, _, _ = m.updateSchedule(tea.KeyPressMsg{Code: tea.KeyEnter}) + if m.scheduleUI.open || !m.hasSchedule("root-one") || c.answers != nil { + t.Fatal("save sent immediately or lost schedule") + } + if !strings.Contains(m.renderMonitorContextDetail(100, 30, paletteFor(m.theme)), "TRIGGER SET") { + t.Fatal("missing signpost") + } + if cmd := m.dispatchSchedules(); cmd != nil { + t.Fatal("dispatch despite exhausted quota") + } + m.openSchedule() + m.scheduleUI.focus = 6 + m, _, _ = m.updateSchedule(tea.KeyPressMsg{Code: tea.KeyEnter}) + if !m.hasSchedule("root-one") { + t.Fatal("Back deleted existing trigger") + } + m.triggerAction("ctrl+d") + if m.hasSchedule("root-one") { + t.Fatal("cancel failed") + } +} + +func TestScheduleDispatchAndBusyGuard(t *testing.T) { + m, c := promptTestModel() + m.monitorPrompt.input.Blur() + m.snapshot = codex.Snapshot{AccountFingerprint: "a", FetchedAt: time.Now(), RateLimits: codex.RateLimitSnapshot{Primary: &codex.Window{UsedPercent: 10}}} + m.scheduleUI.queue = schedule.New() + if err := m.scheduleUI.queue.Save(schedule.Job{Session: "root-one", Text: "hello", Trigger: "quota"}, "a", time.Now()); err != nil { + t.Fatal(err) + } + m.scheduleUI.open = true + if m.dispatchSchedules() != nil { + t.Fatal("competed with editor") + } + m.scheduleUI.open = false + cmd := m.dispatchSchedules() + if cmd == nil { + t.Fatal("missing dispatch") + } + cmd() + if len(c.answers) != 1 || c.answers[0] != "hello" { + t.Fatal(c.answers) + } + if m.hasSchedule("root-one") { + t.Fatal("sent job still pending") + } +} + +func TestScheduleMouseSurfacesAndCalendar(t *testing.T) { + m, _ := promptTestModel() + m.width = 100 + m.height = 40 + m.openSchedule() + for _, tc := range []struct{ x, mode int }{{3, 0}, {26, 1}, {36, 2}} { + m, _, _ = m.updateSchedule(scheduleFormClick(m, tc.x, 7)) + if m.scheduleUI.mode != tc.mode { + t.Fatal("wrong trigger click", tc) + } + } + m.scheduleUI.date = time.Date(2026, time.January, 31, 12, 0, 0, 0, time.UTC) + m.scheduleUI.focus = 2 + m, _, _ = m.updateSchedule(tea.KeyPressMsg{Code: tea.KeyPgDown}) + if m.scheduleUI.date.Month() != time.February { + t.Fatal("calendar skipped February") + } + m.width = 30 + _, _, handled := m.updateSchedule(tea.MouseClickMsg{X: 3, Y: 7, Button: tea.MouseLeft}) + if !handled { + t.Fatal("short terminal passed click to underlying screen") + } +} + +func scheduledTestModel(t *testing.T) (Model, *promptTestClient) { + t.Helper() + m, c := promptTestModel() + m.width = 120 + m.height = 45 + m.snapshot = codex.Snapshot{AccountFingerprint: "a", FetchedAt: time.Now(), RateLimits: codex.RateLimitSnapshot{Primary: &codex.Window{UsedPercent: 0}}} + m.scheduleUI.queue = schedule.New() + if err := m.scheduleUI.queue.Save(schedule.Job{Session: "root-one", Text: "The saved trigger prompt", Trigger: "at", At: time.Now().Add(time.Hour)}, "a", time.Now()); err != nil { + t.Fatal(err) + } + return m, c +} + +func TestScheduleFollowupsPanelAndSendNow(t *testing.T) { + m, c := scheduledTestModel(t) + if m.monitorPromptRows(100, 30) == 0 { + t.Fatal("unified panel hid composer") + } + out := m.renderMonitorContextDetail(100, 30, paletteFor(m.theme)) + if !strings.Contains(out, "The saved trigger prompt") || !strings.Contains(out, "AT ") { + t.Fatal("missing pending prompt/time") + } + m.triggerAction("ctrl+n") + if m.scheduleUI.confirmID == "" || c.answers != nil { + t.Fatal("send skipped confirmation") + } + cmd := m.triggerAction("ctrl+y") + if cmd == nil { + t.Fatal("missing confirmed send") + } + cmd() + if len(c.answers) != 1 || c.answers[0] != "The saved trigger prompt" || m.hasSchedule("root-one") { + t.Fatal("wrong dispatch") + } +} + +func TestScheduleButtonsHoverMatchesClickSurfaces(t *testing.T) { + for _, width := range []int{50, 120} { + for _, confirming := range []bool{true} { + m, _ := scheduledTestModel(t) + m.width = width + if confirming { + m.triggerAction("ctrl+n") + } + g := m.monitorDashboardLayout() + _, _, top := monitorContextBodyLayout(g.meterHeight, len(m.schedulePanelLines(g.contentWidth))) + for _, button := range m.triggerButtons(g.contentWidth) { + x, y := 4+button.x, g.meterY+top+button.y + for _, hitX := range []int{x, x + ansi.StringWidth(button.text) - 1} { + if hitX >= g.contentWidth { + continue + } + hit, ok := m.scheduleButtonAt(hitX, y) + if !ok || hit.key != button.key || !hit.enabled { + t.Fatalf("width %d: missing click surface for %s", width, button.key) + } + updated, _ := m.Update(tea.MouseMotionMsg{X: hitX, Y: y}) + n := updated.(Model) + if n.monitorContextHover != "schedule:"+button.key { + t.Fatalf("missing hover for %s: %s", button.key, n.monitorContextHover) + } + c := paletteFor(m.theme) + want := c.label().Foreground(c.background).Background(c.primary).Render(button.text) + if !strings.Contains(strings.Join(n.schedulePanelLines(g.contentWidth), "\n"), want) { + t.Fatal("button not highlighted", button.key) + } + updated, _ = n.Update(tea.MouseMotionMsg{X: 0, Y: 0}) + if updated.(Model).monitorContextHover == "schedule:"+button.key { + t.Fatal("hover stuck after leaving button") + } + } + } + } + } + m, _ := scheduledTestModel(t) + m.triggerAction("ctrl+n") + m.snapshot.RateLimits.Primary.UsedPercent = 100 + g := m.monitorDashboardLayout() + _, _, top := monitorContextBodyLayout(g.meterHeight, len(m.schedulePanelLines(g.contentWidth))) + before := strings.Join(m.schedulePanelLines(g.contentWidth), "\n") + updated, _ := m.Update(tea.MouseMotionMsg{X: 4, Y: g.meterY + top}) + n := updated.(Model) + if n.monitorContextHover != "schedule:disabled" || strings.Join(n.schedulePanelLines(g.contentWidth), "\n") != before { + t.Fatal("disabled Send now gained hover styling") + } + n, cmd, handled := n.schedulePanelKey(tea.MouseClickMsg{X: 4, Y: g.meterY + top, Button: tea.MouseLeft}) + if !handled || cmd != nil || n.scheduleUI.confirmID != m.scheduleUI.confirmID { + t.Fatal("disabled Send now was activated") + } +} + +func TestScheduleFormKeyboardVisitsOnlyVisibleFields(t *testing.T) { + m, _ := scheduledTestModel(t) + m.openSchedule() + for mode, want := range map[int][]int{0: {1, 5, 6, 0}, 1: {1, 2, 3, 5, 6, 0}, 2: {1, 2, 3, 4, 5, 6, 0}} { + m.scheduleUI.mode = mode + m.scheduleUI.focus = 0 + for _, f := range want { + m, _, _ = m.updateSchedule(tea.KeyPressMsg{Code: tea.KeyTab}) + if m.scheduleUI.focus != f { + t.Fatalf("mode %d: wanted %d got %d", mode, f, m.scheduleUI.focus) + } + } + } + m.scheduleUI.mode = 1 + m.scheduleUI.focus = 2 + for _, digit := range "12" { + m, _, _ = m.updateSchedule(tea.KeyPressMsg{Code: digit, Text: string(digit)}) + } + if m.scheduleUI.hours != 12 { + t.Fatal("numeric hour entry failed") + } + m, _, _ = m.updateSchedule(tea.KeyPressMsg{Code: tea.KeyTab}) + m, _, _ = m.updateSchedule(tea.KeyPressMsg{Code: '5', Text: "5"}) + if m.scheduleUI.minutes != 5 { + t.Fatal("numeric buffer carried between fields") + } +} + +func TestSchedulePillLastAndBothClickTargets(t *testing.T) { + m, _ := scheduledTestModel(t) + m.monitorContextDetail = "" + m.monitorSessionData[0].working = false + m.monitorSessionData[0].attention = codex.SessionAttentionComplete + m.monitorSessionData[1].working = false + m.monitorSessionData[1].attention = codex.SessionAttentionComplete + items := m.monitorAttentionSessions() + if len(items) < 2 || !items[len(items)-1].trigger { + t.Fatal("trigger not lowest priority") + } + g := m.monitorDashboardLayout() + a := m.monitorArea(g.contentWidth, g.meterHeight) + foundPill, foundRow := false, false + for y := 0; y < m.height; y++ { + for x := 0; x < m.width; x++ { + if m.monitorContextAt(x, y) != "attention-trigger:root-one" { + continue + } + n, _, handled := m.updateSchedule(tea.MouseClickMsg{X: x, Y: y, Button: tea.MouseLeft}) + if !handled || n.scheduleUI.open || n.monitorContextDetail != "root-one" { + t.Fatal("trigger click did not open correct session detail") + } + if y < g.meterY+a.topHeight+a.gap { + foundPill = true + } else { + foundRow = true + } + } + } + if !foundPill || !foundRow { + t.Fatal("missing click surface", foundPill, foundRow) + } +} + +func TestScheduleReplacesCompleteWithoutMaskingOtherStates(t *testing.T) { + m, _ := scheduledTestModel(t) + s := &m.monitorSessionData[0] + s.working = false + s.attention = codex.SessionAttentionComplete + badge := ansi.Strip(m.renderMonitorSessionBadge(*s, 80, paletteFor(m.theme))) + if !strings.Contains(badge, "TRIGGER SET") || strings.Contains(badge, "TURN COMPLETE") { + t.Fatal("completed badge was not replaced", badge) + } + row := ansi.Strip(m.renderMonitorSessionMetrics(80, 12, *s, "", paletteFor(m.theme))) + if strings.Count(row, "TRIGGER SET") != 1 || strings.Contains(row, "TURN COMPLETE") { + t.Fatal("duplicate or outdated row status", row) + } + detail := ansi.Strip(m.renderMonitorContextDetail(100, 30, paletteFor(m.theme))) + if strings.Count(detail, "TRIGGER SET") != 1 || !strings.Contains(strings.Split(detail, "\n")[0], "TRIGGER SET") { + t.Fatal("trigger state must appear only on detail border", detail) + } + if !strings.Contains(detail, "The saved trigger prompt") || !strings.Contains(detail, "AT ") { + t.Fatal("trigger detail disappeared", detail) + } + count := 0 + for _, item := range m.monitorAttentionSessions() { + if item.id == s.id { + count++ + if !item.trigger { + t.Fatal("duplicate turn complete pill") + } + } + } + if count != 1 { + t.Fatalf("got %d pills for scheduled complete session", count) + } + for _, attention := range []codex.SessionAttention{codex.SessionAttentionApproval, codex.SessionAttentionInput, codex.SessionAttentionCheck, codex.SessionAttentionNone} { + s.attention, s.working = attention, attention == codex.SessionAttentionNone + badge = ansi.Strip(m.renderMonitorSessionBadge(*s, 80, paletteFor(m.theme))) + if strings.Contains(badge, "TRIGGER SET") { + t.Fatal("trigger masked live state", attention) + } + } + s.attention, s.working = codex.SessionAttentionComplete, false + j := m.scheduleUI.queue.List(s.id)[0] + if err := m.scheduleUI.queue.Cancel(s.id, j.ID); err != nil { + t.Fatal(err) + } + badge = ansi.Strip(m.renderMonitorSessionBadge(*s, 80, paletteFor(m.theme))) + if !strings.Contains(badge, "TURN COMPLETE") { + t.Fatal("completion badge not restored", badge) + } + for _, item := range m.monitorAttentionSessions() { + if item.id == s.id && item.trigger { + t.Fatal("trigger pill retained after deletion") + } + } +} + +func TestScheduledIdleDetailTitleHasNoDuplicateStatus(t *testing.T) { + m, _ := scheduledTestModel(t) + m.monitorSessionData[0].attention = codex.SessionAttentionNone + m.monitorSessionData[0].working = false + for phase := 0; phase < 2; phase++ { + m.phase = phase + out := ansi.Strip(m.renderMonitorContextDetail(100, 30, paletteFor(m.theme))) + lines := strings.Split(out, "\n") + if !strings.Contains(lines[0], "● TRIGGER SET") || strings.Count(out, "TRIGGER SET") != 1 || strings.Contains(out, "SESSION CONTEXT") { + t.Fatal("idle trigger state missing, blinking, or repeated", out) + } + if !strings.Contains(out, "FOLLOW-UP //") || !strings.Contains(out, "The saved trigger prompt") { + t.Fatal("trigger information lost", out) + } + } +} + +func TestScheduledPromptDoesNotPrecedeReviews(t *testing.T) { + for _, kind := range []codex.SessionContextKind{codex.SessionContextApproval, codex.SessionContextQuestion} { + m, _ := scheduledTestModel(t) + m.monitorSessionData[0].preview.Kind = kind + m.monitorSessionData[0].preview.Text = "Actual request requiring a response" + out := ansi.Strip(m.renderMonitorContextDetail(100, 30, paletteFor(m.theme))) + if !strings.Contains(out, "Actual request requiring a response") || strings.Index(out, "The saved trigger prompt") < strings.Index(out, "Actual request requiring a response") { + t.Fatal("saved prompt crowded out request", out) + } + if !m.hasSchedule("root-one") { + t.Fatal("hiding saved prompt removed trigger") + } + } + m, _ := profileTestModel(t) + m.scheduleUI.queue = schedule.New() + if err := m.scheduleUI.queue.Save(schedule.Job{Session: "one", Text: "Unrelated scheduled prompt", Trigger: "quota"}, "a", time.Now()); err != nil { + t.Fatal(err) + } + out := ansi.Strip(m.renderMonitorContextDetail(120, 40, paletteFor(m.theme))) + if strings.Contains(out, "Unrelated scheduled prompt") || !strings.Contains(out, "CURRENT PROFILE") { + t.Fatal("saved prompt leaked into profile review", out) + } +} + +func TestScheduleHeadingNamesSessionBeforeID(t *testing.T) { + for _, tc := range []struct{ name, directory, want string }{ + {"Fix dashboard", "/work/project", "Fix dashboard"}, + {"", "/work/project", "project"}, + {"", "", "Unnamed session"}, + } { + m, _ := scheduledTestModel(t) + m.monitorSessionData[0].name = tc.name + m.monitorSessionData[0].workingDirectory = tc.directory + m.openSchedule() + m.scheduleUI.id = "" // Exercise the new-schedule title below. + heading := strings.Split(ansi.Strip(m.renderScheduleForm()), "\n")[0] + if !strings.Contains(heading, "SCHEDULE FOLLOW-UP // "+tc.want+" // "+shortSessionID("root-one")) { + t.Fatalf("wrong heading: %q", heading) + } + m.width = 48 + m.monitorSessionData[0].name = strings.Repeat("Long name ", 20) + heading = strings.Split(ansi.Strip(m.renderScheduleForm()), "\n")[0] + if !strings.Contains(heading, " // "+shortSessionID("root-one")) { + t.Fatalf("narrow heading lost ID: %q", heading) + } + } +} + +func TestScheduleFrameFitsAndPreservesControlRows(t *testing.T) { + for _, size := range [][2]int{{48, 24}, {80, 30}, {120, 45}} { + m, _ := scheduledTestModel(t) + m.width, m.height = size[0], size[1] + m.openSchedule() + for mode := range 3 { + m.scheduleUI.mode = mode + m.scheduleUI.notice = "Test notice" + lines := strings.Split(ansi.Strip(m.renderScheduleForm()), "\n") + if len(lines) != m.height { + t.Fatalf("frame height %d, want %d", len(lines), m.height) + } + for _, line := range lines { + if ansi.StringWidth(line) != m.width { + t.Fatalf("frame width mismatch: %q", line) + } + } + if !strings.HasPrefix(lines[0], "╭") || !strings.HasSuffix(lines[len(lines)-1], "╯") { + t.Fatal("missing frame") + } + noticeRow := 19 + if mode != 2 { + noticeRow = m.scheduleUI.actionRow() + 1 + } + if !strings.Contains(lines[7], "AFTER QUOTA REFRESH") || !strings.Contains(lines[noticeRow], "Test notice") || !strings.Contains(lines[m.scheduleUI.actionRow()], "SAVE CHANGES") { + t.Fatal("control rows moved") + } + } + } +} + +func TestDelayFieldsAndActionsStayTogether(t *testing.T) { + m, _ := scheduledTestModel(t) + m.openSchedule() + m.scheduleUI.mode = 1 + lines := strings.Split(ansi.Strip(m.renderScheduleForm()), "\n") + if !strings.Contains(lines[9], "Hours:") || !strings.Contains(lines[9], "Minutes:") || !strings.Contains(lines[13], "SAVE CHANGES") { + t.Fatal("delay layout still reserves calendar space") + } + m, _, _ = m.updateSchedule(scheduleFormClick(m, 23, 9)) + if m.scheduleUI.focus != 3 { + t.Fatal("minutes click misaligned") + } + m, _, _ = m.updateSchedule(scheduleFormClick(m, 3, 9)) + if m.scheduleUI.focus != 2 { + t.Fatal("hours click misaligned") + } + m, _, _ = m.updateSchedule(scheduleFormClick(m, 26, 13)) + if m.scheduleUI.open || !m.hasSchedule("root-one") { + t.Fatal("Back target misaligned or deleted trigger") + } +} + +func TestScheduleQuitDoesNotStealComposerText(t *testing.T) { + m, _ := scheduledTestModel(t) + m.openSchedule() + m.scheduleUI.input.SetValue("") + m, cmd, handled := m.updateSchedule(tea.KeyPressMsg{Code: 'q', Text: "q"}) + if !handled || m.scheduleUI.input.Value() != "q" { + t.Fatal("q did not type into composer") + } + if cmd != nil { + if _, quit := cmd().(tea.QuitMsg); quit { + t.Fatal("q quit from composer") + } + } + for focus := 1; focus <= 6; focus++ { + m.scheduleUI.focus = focus + _, cmd, handled = m.updateSchedule(tea.KeyPressMsg{Code: 'q', Text: "q"}) + if !handled || cmd == nil { + t.Fatalf("q not handled at field %d", focus) + } + if _, ok := cmd().(tea.QuitMsg); !ok { + t.Fatalf("q did not quit at field %d", focus) + } + } + m.scheduleUI.focus = 0 + _, cmd, _ = m.updateSchedule(tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl}) + if cmd == nil { + t.Fatal("Ctrl+C did not quit from composer") + } + if _, ok := cmd().(tea.QuitMsg); !ok { + t.Fatal("Ctrl+C returned wrong command") + } +} + +func TestDismissedSessionKeepsManageableTriggerPill(t *testing.T) { + m, _ := scheduledTestModel(t) + m.dismissMonitorSession("root-one") + found := false + for _, item := range m.monitorAttentionSessions() { + if item.id == "root-one" && item.trigger { + found = true + } + } + if !found { + t.Fatal("dismissal hid pending trigger") + } + for y := 0; y < m.height; y++ { + for x := 0; x < m.width; x++ { + if m.monitorAttentionAt(x, y) != "attention-trigger:root-one" { + continue + } + n, _, handled := m.updateSchedule(tea.MouseClickMsg{X: x, Y: y, Button: tea.MouseLeft}) + if !handled || n.scheduleUI.open || n.monitorContextDetail != "root-one" || !n.monitorSessionVisible(n.monitorSessionData[0]) { + t.Fatal("trigger pill cannot reopen dismissed session") + } + return + } + } + t.Fatal("trigger pill not clickable") +} diff --git a/internal/ui/monitor_schedule_toggle.go b/internal/ui/monitor_schedule_toggle.go new file mode 100644 index 0000000..3043c39 --- /dev/null +++ b/internal/ui/monitor_schedule_toggle.go @@ -0,0 +1,68 @@ +package ui + +import ( + "github.com/charmbracelet/x/ansi" + "github.com/merefield/codexometer/internal/i18n" +) + +// Reserve the whole button before truncating the session heading, and share +// those exact display columns with hover/click handling. +func scheduleToggleParts(width int, identity string, open bool) (prefix, button string) { + prefix = i18n.Text("FOLLOW-UP") + " // " + identity + button = i18n.Text("[ Ctrl+S: SCHEDULE ]") + if open { + button = i18n.Text("[ Ctrl+S: CLOSE SCHEDULE ]") + } + inner := max(width-4, 1) + if inner < ansi.StringWidth(button) { + return ansi.Truncate(prefix, inner, "…"), "" + } + room := inner - ansi.StringWidth(button) - 4 + if room <= 0 { + return "", button + } + return ansi.Truncate(prefix, room, "…") + " // ", button +} + +func (m Model) renderScheduleToggleHeader(width int, identity string, open bool, colors palette) string { + prefix, button := scheduleToggleParts(width, identity, open) + style := colors.label().Foreground(colors.primary) + if m.scheduleUI.toggleHover { + style = style.Foreground(colors.background).Background(colors.primary) + } + return colors.label().Render(prefix) + style.Render(button) +} + +func (m Model) scheduleToggleAt(x, y int) bool { + if m.meterView != viewMonitor || m.monitorContextDetail == "" || m.contextTargetHidden() { + return false + } + g := m.monitorDashboardLayout() + identity := "" + if s, ok := m.contextDetailSession(); ok { + identity = monitorSessionIdentity(s) + } else { + return false + } + var row int + if m.scheduleUI.open { + layout := m.schedulePaneLayout(g.contentWidth, g.meterHeight) + if layout.composerRows < 2 { + return false + } + row = layout.composerY + } else { + o := m.monitorPromptOffer() + if o.Token == "" || o.TurnID != "" || len(o.Questions) > 0 { + return false + } + rows := m.monitorPromptRows(g.contentWidth, g.meterHeight) + if rows == 0 { + return false + } + _, _, row = monitorContextBodyLayout(g.meterHeight, rows) + } + prefix, button := scheduleToggleParts(g.contentWidth, identity, m.scheduleUI.open) + start := 4 + ansi.StringWidth(prefix) + return button != "" && y == g.meterY+row && x >= start && x < start+ansi.StringWidth(button) +} diff --git a/internal/ui/monitor_schedule_toggle_test.go b/internal/ui/monitor_schedule_toggle_test.go new file mode 100644 index 0000000..169d2d3 --- /dev/null +++ b/internal/ui/monitor_schedule_toggle_test.go @@ -0,0 +1,62 @@ +package ui + +import ( + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/x/ansi" +) + +func TestScheduleToggleRenderedHoverAndClicks(t *testing.T) { + for _, width := range []int{60, 100, 160} { + m, _ := promptTestModel() + m.width = width + m.monitorSessionData[0].name = strings.Repeat("Long session 日本語 ", 8) + for _, open := range []bool{false, true} { + label := "[ Ctrl+S: SCHEDULE ]" + if open { + label = "[ Ctrl+S: CLOSE SCHEDULE ]" + } + rendered := strings.Split(m.render(), "\n") + x, y := -1, -1 + for row, line := range rendered { + plain := ansi.Strip(line) + if index := strings.Index(plain, label); index >= 0 { + x, y = ansi.StringWidth(plain[:index]), row + break + } + } + if x < 0 { + t.Fatalf("width %d missing complete button %q", width, label) + } + for column := x; column < x+ansi.StringWidth(label); column++ { + if !m.scheduleToggleAt(column, y) { + t.Fatalf("button cell not clickable: %d,%d", column, y) + } + } + if m.scheduleToggleAt(x-1, y) || m.scheduleToggleAt(x+ansi.StringWidth(label), y) || m.scheduleToggleAt(x, y+1) { + t.Fatal("hit surface leaked outside button") + } + next, _ := m.Update(tea.MouseMotionMsg{X: x, Y: y}) + m = next.(Model) + if !m.scheduleUI.toggleHover { + t.Fatal("hover not retained through Update") + } + hover := strings.Split(m.render(), "\n")[y] + if hover == rendered[y] || ansi.Strip(hover) != ansi.Strip(rendered[y]) { + t.Fatal("hover missing or moved heading") + } + next, _ = m.Update(tea.MouseMotionMsg{X: 0, Y: 0}) + m = next.(Model) + if m.scheduleUI.toggleHover { + t.Fatal("hover did not clear") + } + next, _ = m.Update(tea.MouseClickMsg{X: x, Y: y, Button: tea.MouseLeft}) + m = next.(Model) + if m.scheduleUI.open == open { + t.Fatalf("%q did not toggle", label) + } + } + } +} diff --git a/internal/ui/monitor_stream_test.go b/internal/ui/monitor_stream_test.go new file mode 100644 index 0000000..69d1deb --- /dev/null +++ b/internal/ui/monitor_stream_test.go @@ -0,0 +1,66 @@ +package ui + +import ( + "strings" + "testing" + "time" + + "github.com/merefield/codexometer/internal/codex" +) + +func TestCurrentTaskAndStreamingDetail(t *testing.T) { + m := approvalTestModel() + c := codex.SessionContext{Kind: codex.SessionContextReply, Text: "Partial reply", CurrentTask: "Original task", LatestGuidance: "New guidance", Streaming: true} + m.monitorSessionData[0].preview = c + text := strings.Join(m.contextDetailLines(80), "\n") + for _, want := range []string{"CURRENT TASK", "Original task", "LATEST GUIDANCE", "New guidance", "REPLY // STREAMING", "Partial reply"} { + if !strings.Contains(text, want) { + t.Fatal(want, text) + } + } + if strings.Index(text, "Original task") > strings.Index(text, "Partial reply") { + t.Fatal("task not above reply") + } + wide := strings.Join(expandedContextLines(80, m.monitorSessionData[0]), "\n") + if !strings.Contains(wide, "Original task") || !strings.Contains(wide, "New guidance") { + t.Fatal(wide) + } + c.Text = strings.Repeat("reply line\n", 100) + m.monitorSessionData[0].preview = c + m.monitorContextScroll = m.monitorContextScrollLimit() + old := m.monitorContextScroll + s := m.monitorSessionData[0] + c.Text += strings.Repeat("more lines\n", 10) + usage := codex.LiveUsageSnapshot{Sessions: []codex.LiveUsageSession{{ID: s.id, TotalTokens: s.latest, Active: true, Working: true, Context: c}}} + m.syncMonitorSessions(usage, time.Now()) + if m.monitorContextScroll <= old || m.monitorContextScroll != m.monitorContextScrollLimit() { + t.Fatal("did not follow growing reply") + } + m.scrollMonitorContext(-5) + old = m.monitorContextScroll + usage.Sessions[0].Context.Text += strings.Repeat("more\n", 10) + m.syncMonitorSessions(usage, time.Now()) + if m.monitorContextScroll != old { + t.Fatal("reading position stolen") + } +} + +func TestCompletedReplyRetainsTaskWithRoom(t *testing.T) { + m := approvalTestModel() + s := m.monitorSessionData[0] + s.preview = codex.SessionContext{Kind: codex.SessionContextReply, Text: "Finished reply", CurrentTask: "Original task", LatestGuidance: "Use Go"} + s.attention = codex.SessionAttentionComplete + m.monitorSessionData[0] = s + doc := strings.Join(m.contextDetailLines(80), "\n") + if !strings.Contains(doc, "Original task") || !strings.Contains(doc, "Use Go") || strings.Contains(doc, "CURRENT TASK") || contextTaskTitle(s.preview) != "TASK" { + t.Fatal(doc) + } + tall := m.renderExpandedContext(80, 24, s, paletteFor(m.theme)) + if !strings.Contains(tall, "Original task") || !strings.Contains(tall, "Finished reply") { + t.Fatal(tall) + } + short := m.renderExpandedContext(80, 4, s, paletteFor(m.theme)) + if strings.Contains(short, "Original task") || !strings.Contains(short, "Finished reply") { + t.Fatal(short) + } +} diff --git a/internal/ui/monitor_summary.go b/internal/ui/monitor_summary.go index d20f328..fa6f142 100644 --- a/internal/ui/monitor_summary.go +++ b/internal/ui/monitor_summary.go @@ -85,41 +85,50 @@ func (m Model) monitorSummaryLines(width, rows int, colors palette) []string { type monitorAttentionItem struct { monitorSession profile bool + trigger bool } func (item monitorAttentionItem) action() string { + if item.trigger { + return "attention-trigger:" + item.id + } if item.profile { return "attention-profile:" + item.id } return "attention:" + item.id } -func (m Model) monitorAttentionSessions() []monitorAttentionItem { - if m.monitorState != monitorRunning || m.monitorError != "" { - return nil - } - var items []monitorAttentionItem - for priority := 0; priority < 4; priority++ { - for _, s := range m.monitorSessionData { - if !m.monitorSessionVisible(s) { - continue - } - _, profile := m.quotaSessionCandidate(s) - include := false - switch priority { - case 0: - include = s.attention == codex.SessionAttentionApproval - case 1: - include = s.attention == codex.SessionAttentionInput - case 2: - include = profile - case 3: - include = s.attention == codex.SessionAttentionComplete && !s.working && !profile - } - if include { - items = append(items, monitorAttentionItem{monitorSession: s, profile: priority == 2}) +func (m *Model) monitorAttentionSessions() []monitorAttentionItem { + fresh := m.monitorState == monitorRunning && m.monitorError == "" + var groups [5][]monitorAttentionItem + for _, s := range m.monitorSessionData { + scheduled := m.hasSchedule(s.id) + if scheduled { + groups[4] = append(groups[4], monitorAttentionItem{monitorSession: s, trigger: true}) + } + if !fresh || !m.monitorSessionVisible(s) { + continue + } + _, profile := m.quotaSessionCandidate(s) + item := monitorAttentionItem{monitorSession: s} + switch s.attention { + case codex.SessionAttentionApproval: + groups[0] = append(groups[0], item) + case codex.SessionAttentionInput: + groups[1] = append(groups[1], item) + case codex.SessionAttentionComplete: + if !s.working && !profile && !scheduled { + groups[3] = append(groups[3], item) } } + if profile { + item.profile = true + groups[2] = append(groups[2], item) + } + } + var items []monitorAttentionItem + for _, group := range groups { + items = append(items, group...) } m.orderMonitorApprovals(items) return items @@ -127,9 +136,9 @@ func (m Model) monitorAttentionSessions() []monitorAttentionItem { // Only the approval group is ranked; the other attention classes retain their // existing priority. Counts remain live while the committed order settles. -func (m Model) orderMonitorApprovals(items []monitorAttentionItem) { +func (m *Model) orderMonitorApprovals(items []monitorAttentionItem) { n := 0 - for n < len(items) && !items[n].profile && items[n].attention == codex.SessionAttentionApproval { + for n < len(items) && !items[n].profile && !items[n].trigger && items[n].attention == codex.SessionAttentionApproval { n++ } ranks := make(map[string]int, len(m.monitorApprovalOrder)) @@ -165,7 +174,7 @@ func (m *Model) settleMonitorApprovalOrder(now time.Time) { items := desired.monitorAttentionSessions() var ids []string for _, item := range items { - if !item.profile && item.attention == codex.SessionAttentionApproval { + if !item.profile && !item.trigger && item.attention == codex.SessionAttentionApproval { ids = append(ids, item.id) } } @@ -215,7 +224,7 @@ func (m *Model) openMonitorAttention(action string) { // A bounded, paged flow layout shared by rendering and hit testing. IDs remain // untouched for navigation; directory names are sanitized before shortening. -func (m Model) monitorAttentionButtons(width, maxRows int) ([]monitorNavigationButton, int) { +func (m *Model) monitorAttentionButtons(width, maxRows int) ([]monitorNavigationButton, int) { sessions := m.monitorAttentionSessions() if len(sessions) == 0 || width < 24 || maxRows < 1 { return nil, 0 @@ -260,7 +269,7 @@ func (m Model) selectedAttentionAction() string { return "attention:" + id } -func (m Model) markSelectedAttention(buttons []monitorNavigationButton) { +func (m *Model) markSelectedAttention(buttons []monitorNavigationButton) { action := m.selectedAttentionAction() if action == "" { return @@ -273,7 +282,7 @@ func (m Model) markSelectedAttention(buttons []monitorNavigationButton) { } } -func (m Model) layoutMonitorAttention(sessions []monitorAttentionItem, width, rows, compact int, truncate bool) []monitorNavigationButton { +func (m *Model) layoutMonitorAttention(sessions []monitorAttentionItem, width, rows, compact int, truncate bool) []monitorNavigationButton { x, y := 0, 0 markerWidth := 0 if m.monitorContextDetail != "" { @@ -295,6 +304,9 @@ func (m Model) layoutMonitorAttention(sessions []monitorAttentionItem, width, ro if compact == 3 && s.attention == codex.SessionAttentionComplete && !s.profile { state = i18n.Text("DONE") } + if s.trigger { + state = i18n.Text("TRIGGER SET") + } if truncate { state = ansi.Truncate(state, max(width-lipgloss.Width(id)-3-markerWidth, 1), "…") } @@ -339,7 +351,7 @@ func (m Model) renderMonitorAttention(width, rows int, buttons []monitorNavigati lines := make([]string, rows) completed := make(map[string]bool) for _, s := range m.monitorAttentionSessions() { - if s.attention == codex.SessionAttentionComplete && !s.profile { + if s.trigger || s.attention == codex.SessionAttentionComplete && !s.profile { completed[s.action()] = true } } @@ -362,7 +374,18 @@ func (m Model) renderMonitorAttention(width, rows int, buttons []monitorNavigati // Overview attention fits between the summary and session rows; retain at least // one useful row. The full detail budget additionally protects controls/text. +type monitorGeometryCache struct { + areas map[[2]int]monitorGeometry + dashboard *dashboardGeometry +} + func (m Model) monitorArea(width, height int) monitorGeometry { + key := [2]int{width, height} + if m.geometry != nil { + if a, ok := m.geometry.areas[key]; ok { + return a + } + } a := layoutMonitorArea(width, height) // Reserve one stable navigation row, even when no session needs attention. // Overflow is paged rather than shifting the session content downwards. @@ -376,6 +399,12 @@ func (m Model) monitorArea(width, height int) monitorGeometry { } a.gap += a.attentionRows a.graphHeight -= a.attentionRows + if m.geometry != nil { + if m.geometry.areas == nil { + m.geometry.areas = make(map[[2]int]monitorGeometry) + } + m.geometry.areas[key] = a + } return a } @@ -419,10 +448,16 @@ func monitorSummaryHeight(width int) int { } func (m Model) monitorDashboardLayout() dashboardGeometry { + if m.geometry != nil && m.geometry.dashboard != nil { + return *m.geometry.dashboard + } g := m.dashboardLayout() summary, rows, _ := m.monitorDetailHeader(g.contentWidth, g.meterHeight) g.meterY += summary + rows g.meterHeight -= summary + rows + if m.geometry != nil { + m.geometry.dashboard = &g + } return g } diff --git a/internal/ui/monitor_turn_test.go b/internal/ui/monitor_turn_test.go new file mode 100644 index 0000000..3411918 --- /dev/null +++ b/internal/ui/monitor_turn_test.go @@ -0,0 +1,234 @@ +package ui + +import ( + "context" + "errors" + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/x/ansi" + "github.com/merefield/codexometer/internal/codex" +) + +type turnTestClient struct { + *promptTestClient + active codex.SessionPromptOffer + action, text string +} + +func inlineTurnTestModel() (Model, *turnTestClient) { + m, idle := inlinePromptTestModel() + idle.offer = codex.SessionPromptOffer{} + c := &turnTestClient{promptTestClient: idle, active: codex.SessionPromptOffer{Token: "working", ThreadID: "root-one", TurnID: "turn"}} + m.fetcher = c + return m, c +} + +func TestInlineWorkingComposerKeysAndClicks(t *testing.T) { + for _, width := range []int{80, 120, 200} { + for _, tc := range []struct { + key rune + action string + }{{tea.KeyEnter, "steer"}, {tea.KeyTab, "queue"}, {tea.KeyEscape, "interrupt"}} { + m, c := inlineTurnTestModel() + m.width = width + w, h := m.monitorPromptSize() + rows := m.monitorPromptRows(w, h) + if rows != 3 || !strings.Contains(ansi.Strip(m.render()), "Tab: queue") { + t.Fatalf("missing working composer at width %d", width) + } + g := m.dashboardLayout() + a := m.monitorArea(g.contentWidth, g.meterHeight) + mw, _, _ := monitorSessionColumnWidths(a.width) + _, _, cy := monitorContextBodyLayout(h, rows) + y := g.meterY + a.topHeight + a.gap - 1 + cy + 1 + for x := mw + 5; x < mw+3+w-2; x++ { + if got := m.monitorContextAt(x, y); got != "prompt" { + t.Fatalf("click %d,%d = %q", x, y, got) + } + } + next, _ := m.Update(tea.MouseClickMsg{X: mw + 5, Y: y, Button: tea.MouseLeft}) + m = next.(Model) + if !m.monitorPrompt.input.Focused() || m.monitorContextDetail != "" { + t.Fatal("inline click navigated instead of focusing") + } + m.monitorPrompt.input.SetValue("Inline task") + m, cmd := promptKey(m, tc.key, "") + if cmd == nil { + t.Fatal("missing turn command") + } + m, _, _ = m.updateMonitorPrompt(cmd()) + if c.action != tc.action || c.text != "Inline task" { + t.Fatal(c.action, c.text) + } + if tc.action == "interrupt" && m.monitorPrompt.input.Value() != "Inline task" { + t.Fatal("interrupt discarded draft") + } + } + } +} + +func TestInlineWorkingComposerSpaceAndSelection(t *testing.T) { + m, c := inlineTurnTestModel() + w, h := m.monitorPromptSize() + m.monitorSessionData[0].preview.Text = strings.Repeat("context\n", 100) + if m.monitorPromptRows(w, h) != 0 { + t.Fatal("composer displaced context") + } + m.monitorSessionData[0].preview.Text = "Working." + m.monitorSessionData[0].preview.Kind = codex.SessionContextApproval + if m.monitorPromptRows(w, h) != 0 { + t.Fatal("composer displaced approval") + } + m.monitorSessionData[0].preview.Kind = codex.SessionContextActivity + m.focusMonitorPrompt() + m.monitorPrompt.input.SetValue("Private draft") + next, _ := m.Update(tea.WindowSizeMsg{Width: 60, Height: 14}) + m = next.(Model) + w, h = m.monitorPromptSize() + if m.monitorPromptRows(w, h) != 0 || m.monitorPrompt.input.Focused() { + t.Fatal("hidden composer retained focus") + } + if c.action != "" { + t.Fatal("resize sent input") + } + next, _ = m.Update(tea.WindowSizeMsg{Width: 160, Height: 40}) + m = next.(Model) + m.focusMonitorPrompt() + if m.monitorPrompt.input.Value() != "Private draft" { + t.Fatal("resize lost draft") + } + m.monitorSelectedID = "another" + m, _, _ = m.updateMonitorPrompt(tea.KeyPressMsg(tea.Key{Code: tea.KeyEnter})) + if m.monitorPrompt.input.Value() != "" || m.monitorPrompt.input.Focused() || c.action != "" { + t.Fatal("draft or action leaked to new selection") + } +} + +func TestInlineWorkingComposerSingleOwner(t *testing.T) { + m, idle := promptTestModel() + idle.offer = codex.SessionPromptOffer{} + m.width, m.height = 180, 65 + m.monitorSessionData = m.monitorSessionData[:2] + for i := range m.monitorSessionData { + m.monitorSessionData[i].preview.Text = "Working." + m.setRowContext(m.monitorSessionData[i].id, contextWide) + } + id := m.monitorSessionData[1].id + c := &turnTestClient{promptTestClient: idle, active: codex.SessionPromptOffer{Token: "second-turn", ThreadID: id, TurnID: "turn-two"}} + m.fetcher = c + if got := strings.Count(ansi.Strip(m.render()), "Tab: queue"); got != 1 { + t.Fatalf("rendered %d working composers, want one", got) + } + m.focusMonitorPrompt() + if m.monitorPrompt.session != id { + t.Fatal("wrong session owns composer") + } + m.monitorPrompt.input.SetValue("Only second session") + m, cmd := promptKey(m, tea.KeyTab, "") + if cmd == nil { + t.Fatal("missing queue command") + } + m, _, _ = m.updateMonitorPrompt(cmd()) + if c.action != "queue" || c.text != "Only second session" { + t.Fatal("queue targeted wrong session") + } + m.selectMonitorSession(-1) + if m.monitorPromptOffer().Token != "" || m.monitorPrompt.input.Focused() { + t.Fatal("selection change retained previous working composer") + } +} + +func (c *turnTestClient) SessionTurn(id string) codex.SessionPromptOffer { + if id == c.active.ThreadID { + return c.active + } + return codex.SessionPromptOffer{} +} +func (c *turnTestClient) SendSessionTurn(_ context.Context, o codex.SessionPromptOffer, action, text string) error { + if o.Token != c.active.Token { + return errors.New("expired") + } + c.action, c.text = action, text + return c.err +} +func turnTestModel() (Model, *turnTestClient) { + m, idle := promptTestModel() + idle.offer = codex.SessionPromptOffer{} + c := &turnTestClient{promptTestClient: idle, active: codex.SessionPromptOffer{Token: "working", ThreadID: "root-one", TurnID: "turn"}} + m.fetcher = c + m.focusMonitorPrompt() + m.monitorPrompt.input.SetValue("Keep this draft") + return m, c +} +func TestWorkingComposerKeys(t *testing.T) { + for _, tc := range []struct { + key rune + action string + }{{tea.KeyEnter, "steer"}, {tea.KeyTab, "queue"}, {tea.KeyEscape, "interrupt"}} { + t.Run(tc.action, func(t *testing.T) { + m, c := turnTestModel() + m, cmd := promptKey(m, tc.key, "") + if cmd == nil { + t.Fatal("missing command") + } + m, _, _ = m.updateMonitorPrompt(cmd()) + if c.action != tc.action || c.text != "Keep this draft" { + t.Fatal(c.action, c.text) + } + if tc.action == "interrupt" && m.monitorPrompt.input.Value() != "Keep this draft" { + t.Fatal("draft lost") + } + if tc.action != "interrupt" && m.monitorPrompt.input.Value() != "" { + t.Fatal("sent text remained") + } + }) + } +} +func TestWorkingComposerTransitionKeepsDraftWithoutSending(t *testing.T) { + m, c := turnTestModel() + c.active = codex.SessionPromptOffer{} + c.offer = codex.SessionPromptOffer{Token: "idle", ThreadID: "root-one"} + m, cmd := promptKey(m, tea.KeyEnter, "") + if cmd != nil || len(c.answers) != 0 || m.monitorPrompt.input.Value() != "Keep this draft" { + t.Fatal("transition sent or lost draft") + } + m, cmd = promptKey(m, tea.KeyEnter, "") + if cmd == nil { + t.Fatal("idle send unavailable") + } + cmd() + if len(c.answers) != 1 || c.answers[0] != "Keep this draft" { + t.Fatal(c.answers) + } +} +func TestWorkingComposerErrorAndScheduleIsolation(t *testing.T) { + m, c := turnTestModel() + m.openSchedule() + if m.scheduleUI.open { + t.Fatal("working offer enabled scheduler") + } + c.err = errors.New("unsupported") + m, cmd := promptKey(m, tea.KeyTab, "") + m, _, _ = m.updateMonitorPrompt(cmd()) + if m.monitorPrompt.input.Value() != "Keep this draft" || !strings.Contains(m.monitorPrompt.notice, "unconfirmed") { + t.Fatal("failed queue lost draft or hid error") + } + view := m.renderMonitorPrompt(120, 30, paletteFor(themeHacker)) + if !strings.Contains(view, monitorDotWave(m.phase)) || !strings.Contains(view, "WORKING") { + t.Fatal("missing progress above composer") + } +} + +func TestWorkingComposerBusyKeysDoNotReachDashboard(t *testing.T) { + m, _ := turnTestModel() + m, _ = promptKey(m, tea.KeyEnter, "") + for _, text := range []string{"q", "t", "r"} { + var cmd tea.Cmd + m, cmd = promptKey(m, []rune(text)[0], text) + if cmd != nil || m.monitorPrompt.input.Value() != "Keep this draft" { + t.Fatal("busy editor leaked dashboard key or modified in-flight input") + } + } +} diff --git a/internal/ui/view.go b/internal/ui/view.go index f21e862..70a607d 100644 --- a/internal/ui/view.go +++ b/internal/ui/view.go @@ -24,6 +24,7 @@ func (m Model) View() tea.View { } func (m Model) render() string { + m.geometry = &monitorGeometryCache{} colors := paletteFor(m.theme) layout := m.dashboardLayout() contentWidth := layout.contentWidth diff --git a/internal/web/control.go b/internal/web/control.go index 80866db..6e42be0 100644 --- a/internal/web/control.go +++ b/internal/web/control.go @@ -16,12 +16,14 @@ import ( "time" "github.com/merefield/codexometer/internal/codex" + "github.com/merefield/codexometer/internal/schedule" ) // Control capabilities never enter the shared snapshot. A keyed offer digest // binds the browser's selection to the exact source request without disclosing // Codex tokens. One pending confirmation bounds memory (including secret input). type control struct { + schedules *schedule.Queue profiles *profileControl mu sync.Mutex store *store @@ -68,12 +70,16 @@ type offeredAction struct { } type actionRequest struct { - Review string `json:"review,omitempty"` - Session string `json:"session"` - Offer string `json:"offer"` - Choice *int `json:"choice,omitempty"` - Answers []string `json:"answers,omitempty"` - Confirmation string `json:"confirmation,omitempty"` + EditID string `json:"editId,omitempty"` + SendID string `json:"sendId,omitempty"` + Schedule *scheduleRule `json:"schedule,omitempty"` + CancelID string `json:"cancelId,omitempty"` + Review string `json:"review,omitempty"` + Session string `json:"session"` + Offer string `json:"offer"` + Choice *int `json:"choice,omitempty"` + Answers []string `json:"answers,omitempty"` + Confirmation string `json:"confirmation,omitempty"` } type preparedAction struct { @@ -83,7 +89,7 @@ type preparedAction struct { } func newControl(source Source, store *store) *control { - c := &control{store: store, key: rand.Text()} + c := &control{store: store, key: rand.Text(), schedules: schedule.New()} c.approvals, _ = source.(codex.SessionApprovalClient) c.prompts, _ = source.(codex.SessionPromptClient) c.profiles = newProfileControl(source) @@ -156,6 +162,18 @@ func (c *control) offer(id string) (offeredAction, error) { } func validAction(o offeredAction, r actionRequest) bool { + if len(r.EditID) > 128 || (r.EditID != "" && r.Schedule == nil) { + return false + } + if r.SendID != "" && (r.Schedule != nil || o.Kind != "prompt" || len(o.Questions) > 0) { + return false + } + if r.CancelID != "" { + return false + } + if r.Schedule != nil && (o.Kind != "prompt" || len(o.Questions) > 0 || !r.Schedule.valid(time.Now())) { + return false + } if o.ID == "" || o.ID != r.Offer { return false } @@ -200,6 +218,10 @@ func (c *control) handle(action, origin string) http.HandlerFunc { http.Error(w, "Invalid request", 400) return } + if action == "schedules" { + c.handleSchedules(w, r, body) + return + } c.mu.Lock() if c.pending != nil && !time.Now().Before(c.pending.until) { c.pending = nil @@ -238,6 +260,22 @@ func (c *control) handle(action, origin string) http.HandlerFunc { _ = json.NewEncoder(w).Encode(o.actionOffer) return } + if body.SendID != "" { + j, ok := c.sendNowJob(body.Session, body.SendID) + if !ok { + http.Error(w, errUnavailable.Error(), 409) + return + } + body.Answers = []string{j.Text} + } + if o.Kind == "prompt" && len(o.Questions) == 0 && body.SendID == "" && body.Schedule == nil { + for _, j := range c.schedules.List(body.Session) { + if j.Status != "sent" { + http.Error(w, "Pending trigger: edit, delete or use Send now", 409) + return + } + } + } if !validAction(o, body) { http.Error(w, errUnavailable.Error(), 409) return @@ -262,10 +300,28 @@ func (c *control) handle(action, origin string) http.HandlerFunc { _ = json.NewEncoder(w).Encode(map[string]any{"confirmation": p.id, "expires": p.until}) return } - if o.Kind == "profile" { + if body.SendID != "" { + q := c.scheduleSnapshot() + err = c.schedules.DispatchNow(ctx, body.SendID, q.AccountFingerprint, time.Now(), func(ctx context.Context, j schedule.Job) error { + return c.prompts.SendSessionPrompt(ctx, o.token, []string{j.Text}) + }) + c.publishSchedules() + } else if o.Kind == "profile" { err = c.commitProfile(ctx, o, *body.Choice) } else if o.Kind == "approval" { err = c.approvals.RespondSessionApproval(ctx, o.token, o.decisions[*body.Choice]) + } else if body.Schedule != nil { + q := c.scheduleSnapshot() + if !schedule.Fresh(q, time.Now()) || c.scheduleBlocked(body.Session) { + http.Error(w, errUnavailable.Error(), 409) + return + } + err = c.schedules.SaveIfCurrent(schedule.Job{Session: body.Session, Text: body.Answers[0], Trigger: body.Schedule.Trigger, At: body.Schedule.At, Zone: body.Schedule.Zone, Windows: schedule.WindowKeys(q)}, q.AccountFingerprint, time.Now(), body.EditID) + if err != nil { + http.Error(w, err.Error(), 409) + return + } + c.publishSchedules() } else { err = c.prompts.SendSessionPrompt(ctx, o.token, body.Answers) } diff --git a/internal/web/control_test.go b/internal/web/control_test.go index 8b166bd..7ae8be9 100644 --- a/internal/web/control_test.go +++ b/internal/web/control_test.go @@ -365,3 +365,19 @@ func TestReplyIsNotHiddenByPreviousApprovalJustification(t *testing.T) { t.Fatal("approval context leaked into reply") } } + +func TestLiveTaskAndStreamProjection(t *testing.T) { + s := newStore() + row := codex.LiveUsageSession{ID: "session", Context: codex.SessionContext{Kind: codex.SessionContextReply, Text: "Partial", CurrentTask: "Build\u001b[31m it\u001b[0m", LatestGuidance: "Use Go", Streaming: true}} + s.live(codex.LiveUsageSnapshot{Sessions: []codex.LiveUsageSession{row}}, nil, time.Now()) + got := s.state.Sessions[0] + if got.CurrentTask != "Build it" || got.LatestGuidance != "Use Go" || !got.Streaming || got.Text != "Partial" { + t.Fatal(got) + } + row.Context = codex.SessionContext{Kind: codex.SessionContextReply, Text: "Finished"} + s.live(codex.LiveUsageSnapshot{Sessions: []codex.LiveUsageSession{row}}, nil, time.Now()) + got = s.state.Sessions[0] + if got.CurrentTask != "" || got.LatestGuidance != "" || got.Streaming || got.Text != "Finished" { + t.Fatal(got) + } +} diff --git a/internal/web/dist/assets/index-B4Mh-Ts2.js b/internal/web/dist/assets/index-B4Mh-Ts2.js deleted file mode 100644 index 0a9c49e..0000000 --- a/internal/web/dist/assets/index-B4Mh-Ts2.js +++ /dev/null @@ -1,37 +0,0 @@ -(function(){let e=document.createElement(`link`).relList;if(e&&e.supports&&e.supports(`modulepreload`))return;for(let e of document.querySelectorAll(`link[rel="modulepreload"]`))n(e);new MutationObserver(e=>{for(let t of e)if(t.type===`childList`)for(let e of t.addedNodes)e.tagName===`LINK`&&e.rel===`modulepreload`&&n(e)}).observe(document,{childList:!0,subtree:!0});function t(e){let t={};return e.integrity&&(t.integrity=e.integrity),e.referrerPolicy&&(t.referrerPolicy=e.referrerPolicy),t.credentials=e.crossOrigin===`use-credentials`?`include`:e.crossOrigin===`anonymous`?`omit`:`same-origin`,t}function n(e){if(e.ep)return;e.ep=!0;let n=t(e);fetch(e.href,n)}})();var e=Array.isArray,t=Array.prototype.indexOf,n=Array.prototype.includes,r=Array.from,i=Object.defineProperty,a=Object.getOwnPropertyDescriptor,o=Object.getOwnPropertyDescriptors,s=Object.prototype,c=Array.prototype,l=Object.getPrototypeOf,u=Object.isExtensible;function d(e){return typeof e==`function`}var f=()=>{};function p(e){return e()}function m(e){for(var t=0;t{e=n,t=r}),resolve:e,reject:t}}function g(e,t,n=!1){return e===void 0?n?t():t:e}function _(e,t){if(Array.isArray(e))return e;if(t===void 0||!(Symbol.iterator in e))return Array.from(e);let n=[];for(let r of e)if(n.push(r),n.length===t)break;return n}var v=1024,y=2048,b=4096,x=8192,S=16384,ee=32768,C=1<<25,w=65536,T=1<<19,te=1<<20,E=1<<25,ne=1<<21,re=1<<22,ie=1<<23,D=Symbol(`$state`),ae=Symbol(`component`),oe=Symbol(`legacy props`),se=Symbol(``),ce=Symbol(`attributes`),le=Symbol(`class`),ue=Symbol(`style`),de=Symbol(`text`),fe=Symbol(`form reset`),pe=new class extends Error{name=`StaleReactionError`;message="The reaction that called `getAbortSignal()` was re-run or destroyed"},me=!!globalThis.document?.contentType&&globalThis.document.contentType.includes(`xml`),he={},ge=Symbol(`uninitialized`),_e=`http://www.w3.org/1999/xhtml`;function ve(){console.warn(`https://svelte.dev/e/derived_inert`)}function ye(e){console.warn(`https://svelte.dev/e/hydration_mismatch`)}function be(){console.warn(`https://svelte.dev/e/select_multiple_invalid_value`)}function xe(){console.warn(`https://svelte.dev/e/svelte_boundary_reset_noop`)}var O=!1;function Se(e){O=e}var k;function Ce(e){if(e===null)throw ye(),he;return k=e}function we(){return Ce(cn(k))}function A(e){if(O){if(cn(k)!==null)throw ye(),he;k=e}}function Te(e=1){if(O){for(var t=e,n=k;t--;)n=cn(n);k=n}}function Ee(e=!0){for(var t=0,n=k;;){if(n.nodeType===8){var r=n.data;if(r===`]`){if(t===0)return n;--t}else(r===`[`||r===`[!`||r[0]===`[`&&!isNaN(Number(r.slice(1))))&&(t+=1)}var i=cn(n);e&&n.remove(),n=i}}function De(e){if(!e||e.nodeType!==8)throw ye(),he;return e.data}function Oe(e){return e===this.v}function ke(e,t){return e==e?e!==t||typeof e==`object`&&!!e||typeof e==`function`:t==t}function Ae(e){return!ke(e,this.v)}function je(e){throw Error(`https://svelte.dev/e/lifecycle_outside_component`)}function Me(){throw Error(`https://svelte.dev/e/async_derived_orphan`)}function Ne(e,t,n){throw Error(`https://svelte.dev/e/each_key_duplicate`)}function Pe(e){throw Error(`https://svelte.dev/e/effect_in_teardown`)}function Fe(){throw Error(`https://svelte.dev/e/effect_in_unowned_derived`)}function Ie(e){throw Error(`https://svelte.dev/e/effect_orphan`)}function Le(){throw Error(`https://svelte.dev/e/effect_update_depth_exceeded`)}function Re(e){throw Error(`https://svelte.dev/e/props_invalid_value`)}function ze(){throw Error(`https://svelte.dev/e/state_descriptors_fixed`)}function Be(){throw Error(`https://svelte.dev/e/state_prototype_fixed`)}function Ve(){throw Error(`https://svelte.dev/e/state_unsafe_mutation`)}function He(){throw Error(`https://svelte.dev/e/svelte_boundary_reset_onerror`)}var Ue=!1;function We(){Ue=!0}var j=null;function Ge(e){j=e}function Ke(e,t=!1,n){j={p:j,i:!1,c:null,e:null,s:e,x:null,r:U,l:Ue&&!t?{s:null,u:null,$:[]}:null}}function qe(e){var t=j,n=t.e;if(n!==null){t.e=null;for(var r of n)xn(r)}return e!==void 0&&(t.x=e),t.i=!0,j=t.p,Je(e)}function Je(e={}){return i(e,ae,{value:!0}),e}function Ye(){return!Ue||j!==null&&j.l===null}var Xe=[];function Ze(){var e=Xe;Xe=[],m(e)}function Qe(e){if(Xe.length===0&&!Tt){var t=Xe;queueMicrotask(()=>{t===Xe&&Ze()})}Xe.push(e)}function $e(){for(;Xe.length>0;)Ze()}var et=~(y|b|v);function M(e,t){e.f=e.f&et|t}function tt(e){e.f&512||e.deps===null?M(e,v):M(e,b)}function nt(e,t,n){e.f&2048?t.add(e):e.f&4096&&n.add(e),M(e,v)}function rt(e){O&&sn(e)!==null&&ln(e)}var it=!1;function at(){it||(it=!0,document.addEventListener(`reset`,e=>{Promise.resolve().then(()=>{if(!e.defaultPrevented)for(let t of e.target.elements)t[fe]?.()})},{capture:!0}))}function ot(e){var t=H,n=U;Gn(null),Kn(null);try{return e()}finally{Gn(t),Kn(n)}}function st(e,t,n,r=n){e.addEventListener(t,()=>ot(n));let i=e[fe];e[fe]=i?()=>{i(),r(!0)}:()=>r(!0),at()}function ct(e,t,n,r){let i=Ye()?ft:ht;var a=e.filter(e=>!e.settled),o=t.map(i);if(n.length===0&&a.length===0){r(o);return}var s=U,c=lt(),l=a.length===1?a[0].promise:a.length>1?Promise.all(a.map(e=>e.promise)):null;function u(e){if(!(s.f&16384)){c();try{r([...o,...e])}catch(e){mn(e,s)}ut()}}var d=dt();if(n.length===0){l.then(()=>u([])).finally(d);return}function f(){Promise.all(n.map(e=>mt(e))).then(u).catch(e=>mn(e,s)).finally(d)}l?l.then(()=>{c(),f(),ut()}):f()}function lt(){var e=U,t=H,n=j,r=P;return function(i=!0){Kn(e),Gn(t),Ge(n),i&&!(e.f&16384)&&(r?.activate(),r?.apply())}}function ut(e=!0){Kn(null),Gn(null),Ge(null),e&&P?.deactivate()}function dt(){var e=U,t=e.b,n=P,r=!!t?.is_rendered();return t?.update_pending_count(1,n),n.increment(r,e),()=>{t?.update_pending_count(-1,n),n.decrement(r,e)}}function ft(e){var t=2|y;return U!==null&&(U.f|=T),{ctx:j,deps:null,effects:null,equals:Oe,f:t,fn:e,reactions:null,rv:0,v:ge,wv:0,parent:U,ac:null}}var pt=Symbol(`obsolete`);function mt(e,t,n){let r=U;r===null&&Me();var i=void 0,a=Ht(ge),o=!H,s=new Set;return Tn(()=>{var t=U,n=h();i=n.promise;try{Promise.resolve(e()).then(n.resolve,e=>{e!==pe&&n.reject(e)}).finally(ut)}catch(e){n.reject(e),ut()}var c=P;if(o){if(t.f&32768)var l=dt();if(r.b?.is_rendered())c.async_deriveds.get(t)?.reject(pt);else for(let e of s.values())e.reject(pt);s.add(n),c.async_deriveds.set(t,n)}let u=(e,t=void 0)=>{l?.(),s.delete(n),t!==pt&&(c.activate(),t?(a.f|=ie,Kt(a,t)):(a.f&8388608&&(a.f^=ie),Kt(a,e)),c.deactivate())};n.promise.then(u,e=>u(null,e||`unknown`))}),yn(()=>{for(let e of s)e.reject(pt)}),new Promise(e=>{function t(n){function r(){n===i?e(a):t(i)}n.then(r,r)}t(i)})}function N(e){let t=ft(e);return Jn(t),t}function ht(e){let t=ft(e);return t.equals=Ae,t}function gt(e){var t=e.effects;if(t!==null){e.effects=null;for(var n=0;n{t.ac.abort(pe),t.ac=null}),t.fn!==null&&(t.teardown=f),lr(t,0),An(t))}function bt(e){if(e.effects!==null)for(let t of e.effects)t.teardown&&t.fn!==null&&ur(t)}var xt=null,P=null,St=null,Ct=null,wt=null,Tt=!1,Et=!1,Dt=null,Ot=null,kt=0,At=1,jt=class e{id=At++;#e=!1;linked=!0;#t=null;#n=null;async_deriveds=new Map;current=new Map;previous=new Map;#r=new Set;#i=new Set;#a=0;#o=new Map;#s=null;#c=[];#l=[];#u=new Set;#d=new Set;#f=new Map;#p=new Set;is_fork=!1;#m=!1;constructor(){xt===null?xt=this:(xt.#n=this,this.#t=xt),xt=this}#h(){if(this.is_fork)return!0;for(let n of this.#o.keys()){for(var e=n,t=!1;e.parent!==null;){if(this.#f.has(e)){t=!0;break}e=e.parent}if(!t)return!0}return!1}skip_effect(e){this.#f.has(e)||this.#f.set(e,{d:[],m:[]}),this.#p.delete(e)}unskip_effect(e,t=e=>this.schedule(e)){var n=this.#f.get(e);if(n){this.#f.delete(e);for(var r of n.d)M(r,y),t(r);for(r of n.m)M(r,b),t(r)}this.#p.add(e)}#g(){var e=[];for(let i of this.#c)if(!(i.f&16384||!(i.f&6144))){for(var t=i,n=!1;t.parent!==null;){t=t.parent;var r=t.f;if(r&96){if(!(r&1024)){n=!0;break}t.f^=v}}n||e.push(t)}return this.#c=[],e}#_(){this.#e=!0;for(let e of this.#u)this.#d.delete(e),M(e,y),this.schedule(e);for(let e of this.#d)M(e,b),this.schedule(e);this.apply();for(var t=Dt=[],n=[],r=Ot=[];this.#c.length>0;){kt++>1e3&&(this.#S(),Nt());for(let e of this.#g())try{this.#v(e,t,n)}catch(t){throw Rt(e),this.#h()||this.discard(),t}}if(P=null,r.length>0){var i=e.ensure();for(let e of r)i.schedule(e)}if(Dt=null,Ot=null,this.#h()){this.#x(n),this.#x(t);for(let[e,t]of this.#f)Lt(e,t);r.length>0&&P.#_();return}let a=this.#y();if(a){this.#x(n),this.#x(t),a.#b(this);return}this.#u.clear(),this.#d.clear();for(let e of this.#r)e(this);this.#r.clear(),St=this,Ft(n),Ft(t),St=null,this.#s?.resolve();var o=P;if(this.#a===0&&(this.#c.length===0||o!==null)&&this.#S(),this.#c.length>0){if(o!==null){for(let e of this.#c)o.#c.push(e);this.#c=[]}else o=this}o!==null&&(Bt.clear(),o.#_())}#v(e,t,n){e.f^=v;for(var r=e.first;r!==null;){var i=r.f,a=!!(i&96);if(!(a&&i&1024||i&8192||this.#f.has(r))&&r.fn!==null){a?r.f^=v:i&4?t.push(r):ir(r)&&(i&16&&this.#d.add(r),ur(r));var o=r.first;if(o!==null){r=o;continue}}for(;r!==null;){var s=r.next;if(s!==null){r=s;break}r=r.parent}}}#y(){for(var e=this.#t;e!==null;){if(!e.is_fork){for(let[t,[,n]]of this.current)if(e.current.has(t)&&!n)return e}e=e.#t}return null}#b(e){for(let[t,n]of e.current)!this.previous.has(t)&&e.previous.has(t)&&this.previous.set(t,e.previous.get(t)),this.current.set(t,n);for(let[t,n]of e.async_deriveds){let e=this.async_deriveds.get(t);e&&n.promise.then(e.resolve).catch(e.reject)}e.async_deriveds.clear(),this.transfer_effects(e.#u,e.#d);let t=e=>{var n=e.reactions;if(n!==null&&!(e.f&2&&!(e.f&6144)))for(let e of n){var r=e.f;if(r&2)t(e);else{var i=e;r&4194320&&!this.async_deriveds.has(i)&&(this.#d.delete(i),M(i,y),this.schedule(i))}}};for(let e of this.current.keys())t(e);this.oncommit(()=>e.discard()),e.#S(),P=this,this.#_()}#x(e){for(var t=0;t{this.#m=!1,this.linked&&this.flush()}))}transfer_effects(e,t){for(let t of e)this.#u.add(t);for(let e of t)this.#d.add(e);e.clear(),t.clear()}oncommit(e){this.#r.add(e)}ondiscard(e){this.#i.add(e)}settled(){return(this.#s??=h()).promise}static ensure(){if(P===null){let t=P=new e;!Et&&!Tt&&Qe(()=>{t.#e||t.flush()})}return P}apply(){Ct=null}schedule(e){if(wt=e,e.b?.is_pending&&e.f&16777228&&!(e.f&32768)){e.b.defer_effect(e);return}this.#c.push(e)}#S(){if(this.linked){var e=this.#t,t=this.#n;e===null||(e.#n=t),t===null?xt=e:t.#t=e,this.linked=!1}}};function Mt(e){var t=Tt;Tt=!0;try{var n;for(e&&(P!==null&&!P.is_fork&&P.flush(),n=e());;){if($e(),P===null)return n;P.flush()}}finally{Tt=t}}function Nt(){try{Le()}catch(e){mn(e,wt)}}var Pt=null;function Ft(e){var t=e.length;if(t!==0){for(var n=0;n0)){Bt.clear();for(let e of Pt){if(e.f&24576)continue;let t=[e],n=e.parent;for(;n!==null;)Pt.has(n)&&(Pt.delete(n),t.push(n)),n=n.parent;for(let e=t.length-1;e>=0;e--){let n=t[e];n.f&24576||ur(n)}}Pt.clear()}}Pt=null}}function It(e){P.schedule(e)}function Lt(e,t){if(!(e.f&32&&e.f&1024)){e.f&2048?t.d.push(e):e.f&4096&&t.m.push(e),M(e,v);for(var n=e.first;n!==null;)Lt(n,t),n=n.next}}function Rt(e){M(e,v);for(var t=e.first;t!==null;)Rt(t),t=t.next}var zt=new Set,Bt=new Map,Vt=!1;function Ht(e,t){return{f:0,v:e,reactions:null,equals:Oe,rv:0,wv:0}}function F(e,t){let n=Ht(e,t);return Jn(n),n}function Ut(e,t=!1,n=!0){let r=Ht(e);return t||(r.equals=Ae),Ue&&n&&j!==null&&j.l!==null&&(j.l.s??=[]).push(r),r}function I(e,t,n=!1){return H!==null&&(!Wn||H.f&131072)&&Ye()&&H.f&4325394&&(qn===null||!qn.has(e))&&Ve(),Kt(e,n?Zt(t):t,Ot)}var Wt=null,Gt=0;function Kt(e,t,n=null){if(!e.equals(t)){Hn?Bt.set(e,t):Bt.has(e)||Bt.set(e,e.v);var r=jt.ensure();if(r.capture(e,t),e.f&2){let t=e;e.f&2048&&_t(t),Ct===null&&tt(t)}e.wv=rr(),Wt=null,Gt=0,Xt(e,y,n),Wt=null,Ye()&&U!==null&&U.f&1024&&!(U.f&96)&&(Zn===null?Qn([e]):Zn.push(e)),!r.is_fork&&zt.size>0&&!Vt&&qt()}return t}function qt(){Vt=!1;for(let e of zt){e.f&1024&&M(e,b);let t;try{t=ir(e)}catch{t=!0}t&&ur(e)}zt.clear()}function Jt(e,t=1){var n=W(e),r=t===1?n++:n--;return I(e,n),r}function Yt(e){I(e,e.v+1)}function Xt(e,t,n){var r=e.reactions;if(r!==null){var i=Ye(),a=r.length;if(Gt+=a,Gt>1e5&&Wt===null&&(Wt=new Set),Wt!==null){if(Wt.has(e))return;Wt.add(e)}for(var o=0;o{if(tr===d)return e();var t=H,n=tr;Gn(null),nr(d);var r=e();return Gn(t),nr(n),r};return i&&r.set(`length`,F(t.length,u)),new Proxy(t,{defineProperty(e,t,n){(!(`value`in n)||n.configurable===!1||n.enumerable===!1||n.writable===!1)&&ze();var i=r.get(t);return i===void 0?f(()=>{var e=F(n.value,u);return r.set(t,e),e}):I(i,n.value,!0),!0},deleteProperty(e,t){var n=r.get(t);if(n===void 0){if(t in e){let e=f(()=>F(ge,u));r.set(t,e),Yt(o)}}else I(n,ge),Yt(o);return!0},get(e,n,i){if(n===D)return t;var o=r.get(n),s=n in e;if(o===void 0&&(!s||a(e,n)?.writable)&&(o=f(()=>F(Zt(s?e[n]:ge),u)),r.set(n,o)),o!==void 0){var c=W(o);return c===ge?void 0:c}return Reflect.get(e,n,i)},getOwnPropertyDescriptor(e,t){this.has?.(e,t);var n=Reflect.getOwnPropertyDescriptor(e,t),i=r.get(t);if(i!==void 0){var a=W(i);if(a===ge)return;if(n&&`value`in n)n.value=a;else return{enumerable:!0,configurable:!0,value:a,writable:!0}}return n},has(e,t){if(t===D)return!0;var n=r.get(t),i=n!==void 0&&n.v!==ge||Reflect.has(e,t);return(n!==void 0||U!==null&&(!i||a(e,t)?.writable))&&(n===void 0&&(n=f(()=>F(i?Zt(e[t]):ge,u)),r.set(t,n)),W(n)===ge)?!1:i},set(e,t,n,s){var c=r.get(t),l=t in e;if(i&&t===`length`)for(var d=n;dF(ge,u)),r.set(d+``,p)):I(p,ge)}if(c===void 0)(!l||a(e,t)?.writable)&&(c=f(()=>F(void 0,u)),I(c,Zt(n)),r.set(t,c));else{l=c.v!==ge;var m=f(()=>Zt(n));I(c,m)}var h=Reflect.getOwnPropertyDescriptor(e,t);if(h?.set&&h.set.call(s,n),!l){if(i&&typeof t==`string`){var g=r.get(`length`),_=Number(t);Number.isInteger(_)&&_>=g.v&&I(g,_+1)}Yt(o)}return!0},ownKeys(e){W(o);var t=Reflect.ownKeys(e).filter(e=>{var t=r.get(e);return t===void 0||t.v!==ge});for(var[n,i]of r)i.v!==ge&&!(n in e)&&t.push(n);return t},setPrototypeOf(){Be()}})}function Qt(e){try{if(typeof e==`object`&&e&&D in e)return e[D]}catch{}return e}function $t(e,t){return Object.is(Qt(e),Qt(t))}var en,tn,nn,rn;function an(){if(en===void 0){en=window,tn=/Firefox/.test(navigator.userAgent);var e=Element.prototype,t=Node.prototype,n=Text.prototype;nn=a(t,`firstChild`).get,rn=a(t,`nextSibling`).get,u(e)&&(e[le]=void 0,e[ce]=null,e[ue]=void 0,e.__e=void 0),u(n)&&(n[de]=void 0)}}function on(e=``){return document.createTextNode(e)}function sn(e){return nn.call(e)}function cn(e){return rn.call(e)}function L(e,t){if(!O)return sn(e);var n=sn(k);if(n===null)n=k.appendChild(on());else if(t&&n.nodeType!==3){var r=on();return n?.before(r),Ce(r),r}return t&&fn(n),Ce(n),n}function R(e,t=!1){if(!O){var n=sn(e);return n instanceof Comment&&n.data===``?cn(n):n}if(t){if(k?.nodeType!==3){var r=on();return k?.before(r),Ce(r),r}fn(k)}return k}function z(e,t=!1){if(!O)return sn(e);var n=L(e,t);return A(e),n}function B(e,t=1,n=!1){let r=O?k:e;for(var i;t--;)i=r,r=cn(r);if(!O)return r;if(n){if(r?.nodeType!==3){var a=on();return r===null?i?.after(a):r.before(a),Ce(a),a}fn(r)}return Ce(r),r}function ln(e){e.textContent=``}function un(){return!1}function dn(e,t,n){return t==null||t===`http://www.w3.org/1999/xhtml`?n?document.createElement(e,{is:n}):document.createElement(e):n?document.createElementNS(t,e,{is:n}):document.createElementNS(t,e)}function fn(e){if(e.nodeValue.length<65536)return;let t=e.nextSibling;for(;t!==null&&t.nodeType===3;)t.remove(),e.nodeValue+=t.nodeValue,t=e.nextSibling}function pn(e){var t=U;if(t===null)return H.f|=ie,e;if(!(t.f&32768)&&!(t.f&4))throw e;mn(e,t)}function mn(e,t){if(!(t!==null&&t.f&16384)){for(;t!==null;){if(t.f&128&&!(t.f&33570816)){if(!(t.f&32768))throw e;try{t.b.error(e);return}catch(t){e=t}}t=t.parent}throw e}}function hn(e){U===null&&(H===null&&Ie(e),Fe()),Hn&&Pe(e)}function gn(e,t){var n=t.last;n===null?t.last=t.first=e:(n.next=e,e.prev=n,t.last=e)}function _n(e,t){var n=U;n!==null&&n.f&8192&&(e|=x);var r={ctx:j,deps:null,nodes:null,f:e|y|512,first:null,fn:t,last:null,next:null,parent:n,b:n&&n.b,prev:null,teardown:null,wv:0,ac:null};P?.register_created_effect(r);var i=r;if(e&4)Dt===null?jt.ensure().schedule(r):Dt.push(r);else if(t!==null){try{ur(r)}catch(e){throw Mn(r),e}i.deps===null&&i.teardown===null&&i.nodes===null&&i.first===i.last&&!(i.f&524288)&&(i=i.first,e&16&&e&65536&&i!==null&&(i.f|=w))}if(i!==null&&(i.parent=n,n!==null&&gn(i,n),H!==null&&H.f&2&&!(e&64))){var a=H;(a.effects??=[]).push(i)}return r}function vn(){return H!==null&&!Wn}function yn(e){let t=_n(8,null);return M(t,v),t.teardown=e,t}function bn(e){hn(`$effect`);var t=U.f;if(!H&&t&32&&j!==null&&!j.i){var n=j;(n.e??=[]).push(e)}else return xn(e)}function xn(e){return _n(4|te,e)}function Sn(e){return hn(`$effect.pre`),_n(8|te,e)}function Cn(e){jt.ensure();let t=_n(64|T,e);return(e={})=>new Promise(n=>{e.outro?Fn(t,()=>{Mn(t),n(void 0)}):(Mn(t),n(void 0))})}function wn(e){return _n(4,e)}function Tn(e){return _n(re|T,e)}function En(e,t=0){return _n(8|t,e)}function V(e,t=[],n=[],r=[]){ct(r,t,n,t=>{_n(8,()=>{e(...t.map(W))})})}function Dn(e,t=0){return _n(16|t,e)}function On(e){return _n(32|T,e)}function kn(e){var t=e.teardown;if(t!==null){let n=Hn,r=H;Un(!0),Gn(null);try{t.call(null)}catch(t){mn(t,e.parent)}finally{Un(n),Gn(r)}}}function An(e,t=!1){var n=e.first;for(e.first=e.last=null;n!==null;){let e=n.ac;e!==null&&ot(()=>{e.abort(pe)});var r=n.next;n.f&64?n.parent=null:Mn(n,t),n=r}}function jn(e){for(var t=e.first;t!==null;){var n=t.next;t.f&32||Mn(t),t=n}}function Mn(e,t=!0){var n=!1;(t||e.f&262144)&&e.nodes!==null&&e.nodes.end!==null&&(Nn(e.nodes.start,e.nodes.end),n=!0),e.f|=C,An(e,t&&!n),lr(e,0);var r=e.nodes&&e.nodes.t;if(r!==null)for(let e of r)e.stop();kn(e),e.f^=C,e.f|=S;var i=e.parent;i!==null&&i.first!==null&&Pn(e),e.next=e.prev=e.teardown=e.ctx=e.deps=e.fn=e.nodes=e.ac=e.b=null}function Nn(e,t){for(;e!==null;){var n=e===t?null:cn(e);e.remove(),e=n}}function Pn(e){var t=e.parent,n=e.prev,r=e.next;n!==null&&(n.next=r),r!==null&&(r.prev=n),t!==null&&(t.first===e&&(t.first=r),t.last===e&&(t.last=n))}function Fn(e,t,n=!0){var r=[];e.f|=256,In(e,r,!0);var i=()=>{n&&Mn(e),t&&t()},a=r.length;if(a>0){var o=()=>--a||i();for(var s of r)s.out(o)}else i()}function In(e,t,n){if(!(e.f&8192)){e.f^=x;var r=e.nodes&&e.nodes.t;if(r!==null)for(let e of r)(e.is_global||n)&&t.push(e);for(var i=e.first;i!==null;){var a=i.next;if(!(i.f&64)){var o=!!(i.f&65536)||!!(i.f&32)&&!!(e.f&16);In(i,t,o?n:!1)}i=a}}}function Ln(e){e.f&=-257,Rn(e,!0)}function Rn(e,t){if(!(e.f&256)&&e.f&8192){e.f^=x,e.f&1024||(M(e,y),jt.ensure().schedule(e));for(var n=e.first;n!==null;){var r=n.next,i=!!(n.f&65536)||!!(n.f&32);Rn(n,i?t:!1),n=r}var a=e.nodes&&e.nodes.t;if(a!==null)for(let e of a)(e.is_global||t)&&e.in()}}function zn(e,t){if(e.nodes)for(var n=e.nodes.start,r=e.nodes.end;n!==null;){var i=n===r?null:cn(n);t.append(n),n=i}}var Bn=null,Vn=!1,Hn=!1;function Un(e){Hn=e}var H=null,Wn=!1;function Gn(e){H=e}var U=null;function Kn(e){U=e}var qn=null;function Jn(e){H!==null&&(H.f&2097152||H.f&2)&&(qn??=new Set).add(e)}var Yn=null,Xn=0,Zn=null;function Qn(e){Zn=e}var $n=1,er=0,tr=er;function nr(e){tr=e}function rr(){return++$n}function ir(e){var t=e.f;if(t&2048)return!0;if(t&4096){for(var n=e.deps,r=n.length,i=0;ie.wv)return!0}t&512&&Ct===null&&M(e,v)}return!1}function ar(e,t,n=!0){var r=e.reactions;if(r!==null&&!(qn!==null&&qn.has(e)))for(var i=0;i{e.ac.abort(pe)}),e.ac=null);try{e.f|=ne;var u=e.fn,d=u();e.f|=ee;var f=sr(e);if(Ye()&&Zn!==null&&!Wn&&f!==null&&!(e.f&6146))for(var p=0;p0)for(t.length=Xn+Yn.length,r=0;r{s.ac.abort(pe),s.ac=null,M(s,y)}),yt(s),lr(s,0)}}function lr(e,t){var n=e.deps;if(n!==null)for(var r=t;rn?.call(this,e))}return e.startsWith(`pointer`)||e.startsWith(`touch`)||e===`wheel`?(i.__removed=!1,Qe(()=>{i.__removed||t.addEventListener(e,i,r)})):t.addEventListener(e,i,r),i}function Cr(e,t,n,r,i){var a={capture:r,passive:i},o=Sr(e,t,n,a);(t===document.body||t===window||t===document||t instanceof HTMLMediaElement)&&yn(()=>{o.__removed=!0,t.removeEventListener(e,o,a)})}function G(e,t,n){(t[yr]??={})[e]=n}function wr(e){for(var t=0;t{Er=!1,Tr=null}));var s=0,c=Tr===e&&e[yr];if(c){var l=a.indexOf(c);if(l!==-1&&(t===document||t===window)){e[yr]=t;return}var u=a.indexOf(t);if(u===-1)return;l<=u&&(s=l)}if(o=a[s]||e.target,o!==t){i(e,`currentTarget`,{configurable:!0,get(){return o||n}});var d=H,f=U;Gn(null),Kn(null);try{for(var p,m=[];o!==null&&o!==t;){try{var h=o[yr]?.[r];h!=null&&(!o.disabled||e.target===o)&&h.call(o,e)}catch(e){p?m.push(e):p=e}if(e.cancelBubble)break;s++,o=s{throw e});throw p}}finally{e[yr]=t,delete e.currentTarget,Gn(d),Kn(f)}}}var Or=globalThis?.window?.trustedTypes&&globalThis.window.trustedTypes.createPolicy(`svelte-trusted-html`,{createHTML:e=>e});function kr(e){return Or?.createHTML(e)??e}function Ar(e){var t=dn(`template`);return t.innerHTML=kr(e.replaceAll(``,``)),t.content}function jr(e,t){var n=U;n.nodes===null&&(n.nodes={start:e,end:t,a:null,t:null})}function K(e,t){var n=!!(t&1),r=!!(t&2),i,a=!e.startsWith(``);return()=>{if(O)return jr(k,null),k;i===void 0&&(i=Ar(a?e:``+e),n||(i=sn(i)));var t=r||tn?document.importNode(i,!0):i.cloneNode(!0);if(n){var o=sn(t),s=t.lastChild;jr(o,s)}else jr(t,t);return t}}function Mr(e,t,n=`svg`){var r=!e.startsWith(``),i=!!(t&1),a=`<${n}>${r?e:``+e}`,o;return()=>{if(O)return jr(k,null),k;if(!o){var e=sn(Ar(a));if(i)for(o=document.createDocumentFragment();sn(e);)o.appendChild(sn(e));else o=sn(e)}var t=o.cloneNode(!0);if(i){var n=sn(t),r=t.lastChild;jr(n,r)}else jr(t,t);return t}}function Nr(e,t){return Mr(e,t,`svg`)}function Pr(e=``){if(!O){var t=on(e+``);return jr(t,t),t}var n=k;return n.nodeType===3?fn(n):(n.before(n=on()),Ce(n)),jr(n,n),n}function Fr(){if(O)return jr(k,null),k;var e=document.createDocumentFragment(),t=document.createComment(``),n=on();return e.append(t,n),jr(t,n),e}function q(e,t){if(O){var n=U;(!(n.f&32768)||n.nodes.end===null)&&(n.nodes.end=k),we();return}e!==null&&e.before(t)}function Ir(){if(O&&k&&k.nodeType===8&&k.textContent?.startsWith(`$`)){let e=k.textContent.substring(1);return we(),e}return(window.__svelte??={}).uid??=1,`c${window.__svelte.uid++}`}function Lr(e){let t=0,n=Ht(0),r;return()=>{vn()&&(W(n),En(()=>(t===0&&(r=mr(()=>e(()=>Yt(n)))),t+=1,()=>{Qe(()=>{--t,t===0&&(r?.(),r=void 0,Yt(n))})})))}}var Rr=w|T;function zr(e,t,n,r){new Br(e,t,n,r)}var Br=class{parent;is_pending=!1;transform_error;#e;#t=O?k:null;#n;#r;#i;#a=null;#o=null;#s=null;#c=null;#l=0;#u=0;#d=!1;#f=new Set;#p=new Set;#m=null;#h=Lr(()=>(this.#m=Ht(this.#l),()=>{this.#m=null}));constructor(e,t,n,r){this.#e=e,this.#n=t,this.#r=e=>{var t=U;t.b=this,t.f|=128,n(e)},this.parent=U.b,this.transform_error=r??this.parent?.transform_error??(e=>e),this.#i=Dn(()=>{if(O){let e=this.#t;we();let t=e.data===`[!`;if(e.data.startsWith(`[?`)){let t=JSON.parse(e.data.slice(2));this.#_(t)}else t?this.#y():this.#g()}else this.#b()},Rr),O&&(this.#e=k)}#g(){try{this.#a=On(()=>this.#r(this.#e))}catch(e){this.error(e)}}#_(e){let t=this.#n.failed,{reset:n,invoke_onerror:r}=this.#v(e);Qe(r),t&&(this.#s=On(()=>{t(this.#e,()=>e,()=>n)}))}#v(e){var t=!1,n=!1;let r=()=>{if(t){xe();return}t=!0,n&&He(),this.#s!==null&&Fn(this.#s,()=>{this.#s=null}),this.#S(()=>{this.#b()})};return{reset:r,invoke_onerror:()=>{try{n=!0,this.#n.onerror?.(e,r),n=!1}catch(e){mn(e,this.#i&&this.#i.parent)}}}}#y(){let e=this.#n.pending;e&&(this.is_pending=!0,this.#o=On(()=>e(this.#e)),Qe(()=>{var e=this.#c=document.createDocumentFragment(),t=on(),n=!1;if(e.append(t),this.#a=this.#S(()=>{try{return On(()=>this.#r(t))}catch(e){try{this.error(e),n=!0}catch(e){mn(e,this.#i.parent)}return null}}),this.#a===null){this.#c=null,n&&this.#x(P);return}this.#u===0&&(this.#e.before(e),this.#c=null,Fn(this.#o,()=>{this.#o=null}),this.#x(P))}))}#b(){try{if(this.is_pending=this.has_pending_snippet(),this.#u=0,this.#l=0,this.#a=On(()=>{this.#r(this.#e)}),this.#u>0){var e=this.#c=document.createDocumentFragment();zn(this.#a,e);let t=this.#n.pending;this.#o=On(()=>t(this.#e))}else this.#x(P)}catch(e){this.error(e)}}#x(e){this.is_pending=!1,e.transfer_effects(this.#f,this.#p)}defer_effect(e){nt(e,this.#f,this.#p)}is_rendered(){return!this.is_pending&&(!this.parent||this.parent.is_rendered())}has_pending_snippet(){return!!this.#n.pending}#S(e){var t=U,n=H,r=j;Kn(this.#i),Gn(this.#i),Ge(this.#i.ctx);try{return jt.ensure(),e()}finally{Kn(t),Gn(n),Ge(r)}}#C(e,t){if(!this.has_pending_snippet()){this.parent&&this.parent.#C(e,t);return}this.#u+=e,this.#u===0&&(this.#x(t),this.#o&&Fn(this.#o,()=>{this.#o=null}),this.#c&&=(this.#e.before(this.#c),null))}update_pending_count(e,t){this.#C(e,t),this.#l+=e,!(!this.#m||this.#d)&&(this.#d=!0,Qe(()=>{this.#d=!1,this.#m&&Kt(this.#m,this.#l)}))}get_effect_pending(){return this.#h(),W(this.#m)}error(e){if(!this.#n.onerror&&!this.#n.failed)throw e;P?.is_fork?(this.#a&&P.skip_effect(this.#a),this.#o&&P.skip_effect(this.#o),this.#s&&P.skip_effect(this.#s),P.oncommit(()=>{this.#w(e)})):this.#w(e)}#w(e){this.#a&&=(Mn(this.#a),null),this.#o&&=(Mn(this.#o),null),this.#s&&=(Mn(this.#s),null),O&&(Ce(this.#t),Te(),Ce(Ee()));let t=this.#n.failed,n=e=>{let{reset:n,invoke_onerror:r}=this.#v(e);r(),t&&(this.#s=this.#S(()=>{try{return On(()=>{var r=U;r.b=this,r.f|=128,t(this.#e,()=>e,()=>n)})}catch(e){return mn(e,this.#i.parent),null}}))};Qe(()=>{var t;try{t=this.transform_error(e)}catch(e){mn(e,this.#i&&this.#i.parent);return}typeof t==`object`&&t&&typeof t.then==`function`?t.then(n,e=>mn(e,this.#i&&this.#i.parent)):n(t)})}};function J(e,t){var n=t==null?``:typeof t==`object`?`${t}`:t;n!==(e[de]??=e.nodeValue)&&(e[de]=n,e.nodeValue=`${n}`)}function Vr(e,t){return Ur(e,t)}var Hr=new Map;function Ur(e,{target:t,anchor:n,props:i={},events:a,context:o,intro:s=!0,transformError:c}){an();var l=void 0,u=Cn(()=>{var s=n??t.appendChild(on());zr(s,{pending:()=>{}},t=>{Ke({});var n=j;if(o&&(n.c=o),a&&(i.$$events=a),O&&jr(t,null),l=e(t,i)||Je(),O&&(U.nodes.end=k,k===null||k.nodeType!==8||k.data!==`]`))throw ye(),he;qe()},c);var u=new Set,d=e=>{for(var n=0;n{for(var e of u)for(let n of[t,document]){var r=Hr.get(n),i=r.get(e);--i==0?(n.removeEventListener(e,Dr),r.delete(e),r.size===0&&Hr.delete(n)):r.set(e,i)}xr.delete(d),s!==n&&s.parentNode?.removeChild(s)}});return Wr.set(l,u),l}var Wr=new WeakMap,Gr=class{anchor;#e=new Map;#t=new Map;#n=new Map;#r=new Set;#i=!0;constructor(e,t=!0){this.anchor=e,this.#i=t}#a=e=>{if(this.#e.has(e)){var t=this.#e.get(e),n=this.#t.get(t);if(n)Ln(n),this.#r.delete(t);else{var r=this.#n.get(t);r&&(Ln(r.effect),this.#t.set(t,r.effect),this.#n.delete(t),r.fragment.lastChild.remove(),this.anchor.before(r.fragment),n=r.effect)}for(let[t,n]of this.#e){if(this.#e.delete(t),t===e)break;let r=this.#n.get(n);r&&(Mn(r.effect),this.#n.delete(n))}for(let[e,r]of this.#t){if(e===t||this.#r.has(e))continue;let i=()=>{if(Array.from(this.#e.values()).includes(e)){var t=document.createDocumentFragment();zn(r,t),t.append(on()),this.#n.set(e,{effect:r,fragment:t})}else Mn(r);this.#r.delete(e),this.#t.delete(e)};this.#i||!n?(this.#r.add(e),Fn(r,i,!1)):i()}}};#o=e=>{this.#e.delete(e);let t=Array.from(this.#e.values());for(let[e,n]of this.#n)t.includes(e)||(Mn(n.effect),this.#n.delete(e))};ensure(e,t){var n=P,r=un();if(t&&!this.#t.has(e)&&!this.#n.has(e)){if(r){var i=document.createDocumentFragment(),a=on();i.append(a),this.#n.set(e,{effect:On(()=>t(a)),fragment:i})}else this.#t.set(e,On(()=>t(this.anchor)))}if(this.#e.set(n,e),r){for(let[t,r]of this.#t)t===e?n.unskip_effect(r):n.skip_effect(r);for(let[t,r]of this.#n)t===e?n.unskip_effect(r.effect):n.skip_effect(r.effect);n.oncommit(this.#a),n.ondiscard(this.#o)}else O&&(this.anchor=k),this.#a(n)}};function Y(e,t,n=!1){var r;O&&(r=k,we());var i=new Gr(e),a=n?w:0;function o(e,t){if(O){var n=De(r);if(e!==parseInt(n.substring(1))){var a=Ee();Ce(a),i.anchor=a,Se(!1),i.ensure(e,t),Se(!0);return}}i.ensure(e,t)}Dn(()=>{var e=!1;t((t,n=0)=>{e=!0,o(n,t)}),e||o(-1,null)},a)}var Kr=Symbol(`NaN`);function qr(e,t,n){O&&we();var r=new Gr(e),i=!Ye();Dn(()=>{var e=t();e!==e&&(e=Kr),i&&typeof e==`object`&&e&&(e={}),r.ensure(e,n)})}function X(e,t){return t}function Jr(e,t,n){for(var i=[],a=t.length,o,s=t.length,c=0;c{if(o){if(o.pending.delete(n),o.done.add(n),o.pending.size===0){var t=e.outrogroups;Yr(e,r(o.done)),t.delete(o),t.size===0&&(e.outrogroups=null)}}else--s},!1)}if(s===0){var l=i.length===0&&n!==null&&e.pending.size===0;if(l){var u=n,d=u.parentNode;ln(d),d.append(u),e.items.clear()}Yr(e,t,!l)}else o={pending:new Set(t),done:new Set},(e.outrogroups??=new Set).add(o)}function Yr(e,t,n=!0){var r;if(e.pending.size>0){r=new Set;for(let t of e.pending.values())for(let n of t)r.add(e.items.get(n).e)}for(var i=0;i{var t=i();return e(t)?t:t==null?[]:r(t)}),p,m=new Map,h=!0;function g(e){v.effect.f&16384||(v.pending.delete(e),v.fallback=d,Qr(v,p,c,n,a),d!==null&&(p.length===0?d.f&33554432?(d.f^=E,ei(d,null,c)):Ln(d):Fn(d,()=>{d=null})))}function _(e){v.pending.delete(e)}var v={effect:Dn(()=>{p=W(f);var e=p.length;let t=!1;O&&De(c)===`[!`!=(e===0)&&(c=Ee(),Ce(c),Se(!1),t=!0);for(var r=new Set,u=P,v=un(),y=0;ys(c)):(d=On(()=>s(Xr??=on())),d.f|=E)),e>r.size&&Ne(``,``,``),O&&e>0&&Ce(Ee()),!h){if(m.set(u,r),v){for(let[e,t]of l)r.has(e)||u.skip_effect(t.e);u.oncommit(g),u.ondiscard(_)}else g(u)}t&&Se(!0),W(f)}),flags:n,items:l,pending:m,outrogroups:null,fallback:d};h=!1,O&&(c=k)}function Zr(e){for(;e!==null&&!(e.f&32);)e=e.next;return e}function Qr(e,t,n,i,a){var o=!!(i&8),s=t.length,c=e.items,l=Zr(e.effect.first),u,d=null,f,p=[],m=[],h,g,_,v;if(o)for(v=0;v0){var T=i&4&&s===0?n:null;if(o){for(v=0;v{if(f!==void 0)for(_ of f)_.nodes?.a?.apply()})}function $r(e,t,n,r,i,a,o,s){var c=o&1?o&16?Ht(n):Ut(n,!1,!1):null,l=o&2?Ht(i):null;return{v:c,i:l,e:On(()=>(a(t,c??n,l??i,s),()=>{e.delete(r)}))}}function ei(e,t,n){if(e.nodes)for(var r=e.nodes.start,i=e.nodes.end,a=t&&!(t.f&33554432)?t.nodes.start:n;r!==null;){var o=cn(r);if(a.before(r),r===i)return;r=o}}function ti(e,t,n){t===null?e.effect.first=n:t.next=n,n===null?e.effect.last=t:n.prev=t}function ni(e,t,n){var r;O&&(r=k,we());var i=new Gr(e);Dn(()=>{var e=t()??null;if(O&&De(r)===`[`!=(e!==null)){var a=Ee();Ce(a),i.anchor=a,Se(!1),i.ensure(e,e&&(t=>n(t,e))),Se(!0);return}i.ensure(e,e&&(t=>n(t,e)))},w)}var ri=[...` -\r\f\xA0\v`];function ii(e,t,n){var r=e==null?``:``+e;if(t&&(r=r?r+` `+t:t),n){for(var i of Object.keys(n))if(n[i])r=r?r+` `+i:i;else if(r.length)for(var a=i.length,o=0;(o=r.indexOf(i,o))>=0;){var s=o+a;(o===0||ri.includes(r[o-1]))&&(s===r.length||ri.includes(r[s]))?r=(o===0?``:r.substring(0,o))+r.substring(s+1):o=s}}return r===``?null:r}function ai(e,t=!1){var n=t?` !important;`:`;`,r=``;for(var i of Object.keys(e)){var a=e[i];a!=null&&a!==``&&(r+=` `+i+`: `+a+n)}return r}function oi(e){return e[0]!==`-`||e[1]!==`-`?e.toLowerCase():e}function si(e,t){if(t){var n=``,r,i;if(Array.isArray(t)?(r=t[0],i=t[1]):r=t,e){e=String(e).replaceAll(/\/\*.*?\*\//g,``).trim();var a=!1,o=0,s=!1,c=[];r&&c.push(...Object.keys(r).map(oi)),i&&c.push(...Object.keys(i).map(oi));var l=0,u=-1;let t=e.length;for(var d=0;d{t.every(_i)||(`__defaultValue`in e&&fi(e,!1),`__value`in e&&pi(e,e.__value))});t.observe(e,{childList:!0,subtree:!0,attributes:!0,attributeFilter:[`value`]}),yn(()=>{t.disconnect()})}function hi(e,t,n=t){var r=new WeakSet,i=!0;st(e,`change`,t=>{var i=t?`[selected]`:`:checked`,a;if(e.multiple)a=[].map.call(e.querySelectorAll(i),gi);else{var o=e.querySelector(i)??e.querySelector(`option:not([disabled])`);a=o&&gi(o)}n(a),e.__value=a,P!==null&&r.add(P)}),wn(()=>{var a=t();if(e===document.activeElement){var o=P;if(r.has(o))return}if(pi(e,a,i),i&&a===void 0){var s=e.querySelector(`:checked`);s!==null&&(a=gi(s),n(a))}e.__value=a,i=!1})}function gi(e){return`__value`in e?e.__value:e.value}function _i(e){if(e.target.closest(`selectedcontent`)!==null)return!0;if(e.type===`childList`){var t=[...e.addedNodes,...e.removedNodes];return t.length>0&&t.every(e=>e.nodeName===`SELECTEDCONTENT`)}return!1}var vi=Symbol(`is custom element`),yi=Symbol(`is html`),bi=me?`link`:`LINK`;function xi(e){if(O){var t=!1,n=()=>{if(!t){if(t=!0,e.hasAttribute(`value`)){var n=e.value;Q(e,`value`,null),e.value=n}if(e.hasAttribute(`checked`)){var r=e.checked;Q(e,`checked`,null),e.checked=r}}};e[fe]=n,Qe(n),at()}}function Q(e,t,n,r){var i=Si(e);O&&(i[t]=e.getAttribute(t),t===`src`||t===`srcset`||t===`href`&&e.nodeName===bi)||i[t]!==(i[t]=n)&&(t===`loading`&&(e[se]=n),n==null?e.removeAttribute(t):typeof n!=`string`&&wi(e).has(t)?e[t]=n:e.setAttribute(t,n))}function Si(e){return e[ce]??={[vi]:e.nodeName.includes(`-`),[yi]:e.namespaceURI===_e}}var Ci=new Map;function wi(e){var t=e.getAttribute(`is`)||e.nodeName,n=Ci.get(t);if(n)return n;Ci.set(t,n=new Set);for(var r,i=e,a=Element.prototype;a!==i;){for(var s in r=o(i),r)r[s].set&&s!==`innerHTML`&&s!==`textContent`&&s!==`innerText`&&n.add(s);i=l(i)}return n}function Ti(e,t,n=t){var r=new WeakSet;st(e,`input`,async i=>{var a=i?e.defaultValue:e.value;if(a=ki(e)?Ai(a):a,n(a),P!==null&&r.add(P),await dr(),a!==(a=t())){var o=e.selectionStart,s=e.selectionEnd,c=e.value.length;if(e.value=a??``,s!==null){var l=e.value.length;o===s&&s===c&&l>c?(e.selectionStart=l,e.selectionEnd=l):(e.selectionStart=o,e.selectionEnd=Math.min(s,l))}}}),(O&&e.defaultValue!==e.value||mr(t)==null&&e.value)&&(n(ki(e)?Ai(e.value):e.value),P!==null&&r.add(P)),En(()=>{var n=t();if(e===document.activeElement){var i=P;if(r.has(i))return}ki(e)&&n===Ai(e.value)||(e.type!==`date`||n||e.value)&&n!==e.value&&(e.value=n??``)})}var Ei=new Set;function Di(e,t,n,r,i=r){var a=n.getAttribute(`type`)===`checkbox`,o=e;let s=!1;if(t!==null)for(var c of t)o=o[c]??=[];o.push(n),st(n,`change`,()=>{var e=n.__value;a&&(e=Oi(o,e,n.checked)),i(e)},()=>i(a?[]:null)),En(()=>{var e=r();if(O&&n.defaultChecked!==n.checked){s=!0;return}a?(e||=[],n.checked=e.includes(n.__value)):n.checked=$t(n.__value,e)}),yn(()=>{var e=o.indexOf(n);e!==-1&&o.splice(e,1)}),Ei.has(o)||(Ei.add(o),Qe(()=>{o.sort((e,t)=>e.compareDocumentPosition(t)===4?-1:1),Ei.delete(o)})),Qe(()=>{if(s){var e=a?Oi(o,e,n.checked):o.find(e=>e.checked)?.__value;i(e)}})}function Oi(e,t,n){for(var r=new Set,i=0;i{var n=this.#e.get(e);n.delete(t),n.size===0&&(this.#e.delete(e),this.#t.unobserve(e))}}#r(){return this.#t??=new ResizeObserver(t=>{for(var n of t){e.entries.set(n.target,n);for(var r of this.#e.get(n.target)||[])r(n)}})}}({box:`border-box`});function Mi(e,t,n){var r=ji.observe(e,()=>n(e[t]));wn(()=>(mr(()=>n(e[t])),r))}function Ni(e,t,n,r,i){var a=()=>{r(n[e])};n.addEventListener(t,a),i?En(()=>{n[e]=i()}):a(),(n===document.body||n===window||n===document)&&yn(()=>{n.removeEventListener(t,a)})}function Pi(e=!1){let t=j,n=t.l.u;if(!n)return;let r=()=>hr(t.s);if(e){let e=0,n={},i=ft(()=>{let r=!1,i=t.s;for(let e in i)i[e]!==n[e]&&(n[e]=i[e],r=!0);return r&&e++,e});r=()=>W(i)}n.b.length&&Sn(()=>{Fi(t,r),m(n.b)}),bn(()=>{let e=mr(()=>n.m.map(p));return()=>{for(let t of e)typeof t==`function`&&t()}}),n.a.length&&bn(()=>{Fi(t,r),m(n.a)})}function Fi(e,t){if(e.l.s)for(let t of e.l.s)W(t);t()}var Ii=!1;function Li(e){var t=Ii;try{return Ii=!1,[e(),Ii]}finally{Ii=t}}var Ri={get(e,t){let n=e.props.length;for(;n--;){let r=e.props[n];if(d(r)&&(r=r()),typeof r==`object`&&r&&t in r)return r[t]}},set(e,t,n){let r=e.props.length;for(;r--;){let i=e.props[r];d(i)&&(i=i());let o=a(i,t);if(o&&o.set)return o.set(n),!0}return!1},getOwnPropertyDescriptor(e,t){let n=e.props.length;for(;n--;){let r=e.props[n];if(d(r)&&(r=r()),typeof r==`object`&&r&&t in r){let e=a(r,t);return e&&!e.configurable&&(e.configurable=!0),e}}},has(e,t){if(t===D||t===oe)return!1;for(let n of e.props)if(d(n)&&(n=n()),n!=null&&t in n)return!0;return!1},ownKeys(e){let t=[];for(let n of e.props)if(d(n)&&(n=n()),n){for(let e in n)t.includes(e)||t.push(e);for(let e of Object.getOwnPropertySymbols(n))t.includes(e)||t.push(e)}return t}};function zi(...e){return new Proxy({props:e},Ri)}function Bi(e,t,n,r){var i=!Ue||!!(n&2),o=!!(n&8),s=!!(n&16),c=r,l=!0,u=void 0,d=()=>s&&i?(u??=ft(r),W(u)):(l&&(l=!1,c=s?mr(r):r),c);let f;if(o){var p=D in e||oe in e;f=a(e,t)?.set??(p&&t in e?n=>e[t]=n:void 0)}var m,h=!1;o?[m,h]=Li(()=>e[t]):m=e[t],m===void 0&&r!==void 0&&(m=d(),f&&(i&&Re(t),f(m)));var g=i?()=>{var n=e[t];return n===void 0?d():(l=!0,n)}:()=>{var n=e[t];return n!==void 0&&(c=void 0),n===void 0?c:n};if(i&&!(n&4))return g;if(f){var _=e.$$legacy;return(function(e,t){return arguments.length>0?((!i||!t||_||h)&&f(t?g():e),e):g()})}var v=!1,y=(n&1?ft:ht)(()=>(v=!1,g()));o&&W(y);var b=U;return(function(e,t){if(arguments.length>0){let n=t?W(y):i&&o?Zt(e):e;return I(y,n),v=!0,c!==void 0&&(c=n),e}return Hn&&v||b.f&16384?y.v:W(y)})}function Vi(e){j===null&&je(`onMount`),Ue&&j.l!==null?Hi(j).m.push(e):bn(()=>{let t=mr(e);if(typeof t==`function`)return t})}function Hi(e){var t=e.l;return t.u??={a:[],b:[],m:[]}}typeof window<`u`&&((window.__svelte??={}).v??=new Set).add(`5`);function Ui(e,t){if(e instanceof RegExp)return{keys:!1,pattern:e};var n,r,i,a,o=[],s=``,c=e.split(`/`);for(c[0]||c.shift();i=c.shift();)n=i[0],n===`*`?(o.push(`wild`),s+=`/(.*)`):n===`:`?(r=i.indexOf(`?`,1),a=i.indexOf(`.`,1),o.push(i.substring(1,~r?r:~a?a:i.length)),s+=~r&&!~a?`(?:/([^/]+?))?`:`/([^/]+?)`,~a&&(s+=(~r?`?`:``)+`\\`+i.substring(a))):s+=`/`+i;return{keys:o,pattern:RegExp(`^`+s+(t?`(?=$|/)`:`/?$`),`i`)}}var Wi=new class{#e=F(Gi());get _loc(){return W(this.#e)}set _loc(e){I(this.#e,e)}#t=N(()=>this._loc.location);get _location(){return W(this.#t)}set _location(e){I(this.#t,e)}#n=N(()=>this._loc.querystring);get _querystring(){return W(this.#n)}set _querystring(e){I(this.#n,e)}#r=F(void 0);get _params(){return W(this.#r)}set _params(e){I(this.#r,e)}get loc(){return this._loc}get location(){return this._location}get querystring(){return this._querystring}get params(){return this._params}constructor(){typeof window<`u`?window.addEventListener(`hashchange`,()=>{this._loc=Gi()}):console.warn(`[svelte-spa-router] window 'window' is not defined, skipping initiation`)}};function Gi(){let e=typeof window<`u`?window.location.href:``,t=e.indexOf(`#/`),n=t>-1?e.substr(t+1):`/`,r=n.indexOf(`?`),i=``;return r>-1&&(i=n.substr(r+1),n=n.substr(0,r)),{location:n,querystring:i}}function Ki(e){e?window.scrollTo(e.__svelte_spa_router_scrollX||0,e.__svelte_spa_router_scrollY||0):window.scrollTo(0,0)}function qi(e,t){Ke(t,!0);let n=Bi(t,`routes`,19,()=>({})),r=Bi(t,`prefix`,3,``),i=Bi(t,`restoreScrollState`,3,!1),a=Bi(t,`onConditionsFailed`,3,()=>{}),o=Bi(t,`onRouteLoaded`,3,()=>{}),s=Bi(t,`onRouteLoading`,3,()=>{}),c=Bi(t,`onRouteEvent`,3,()=>{});class l{path;component;conditions;userData;props;_pattern;_keys;constructor(e,t){let n=e=>typeof e==`object`&&!!e&&e._sveltesparouter===!0;if(!t||typeof t!=`function`&&!n(t))throw Error(`Invalid component object`);if(!e||typeof e==`string`&&(e.length<1||e.charAt(0)!=`/`&&e.charAt(0)!=`*`)||typeof e==`object`&&!(e instanceof RegExp))throw Error(`Invalid value for "path" argument - strings must start with / or *`);let r=Ui(e);if(this.path=e,n(t)){let e=t;this.component=e.component,this.conditions=e.conditions||[],this.userData=e.userData,this.props=e.props||{}}else{let e=t;this.component=()=>Promise.resolve(e),this.conditions=[],this.props={}}this._pattern=r.pattern,this._keys=r.keys}match(e){if(r()){if(typeof r()==`string`){if(e.startsWith(r()))e=e.substr(r().length)||`/`;else return null}else if(r()instanceof RegExp){let t=e.match(r());if(t&&t[0])e=e.substr(t[0].length)||`/`;else return null}}let t=this._pattern.exec(e);if(t===null)return null;if(this._keys===!1)return t;let n={},i=0;for(;i{u.push(new l(t,e))}):Object.keys(n()).forEach(e=>{let t=n();u.push(new l(e,t[e]))});let d=F(null),f=F(null),p=F({}),m=null,h=null;bn(()=>{history.scrollRestoration=i()?`manual`:`auto`}),bn(()=>{if(!i())return;let e=e=>{m=e.state&&(e.state.__svelte_spa_router_scrollY||e.state.__svelte_spa_router_scrollX)?e.state:null};return window.addEventListener(`popstate`,e),()=>window.removeEventListener(`popstate`,e)});async function g(e,t){await dr(),e(t)}bn(()=>{let e=Wi.loc,t=!1;return mr(async()=>{let n=0;for(;n{t=!0}});function _(e){return e&&typeof e==`object`&&Object.keys(e).length?e:null}var v=Fr(),y=R(v),b=e=>{let t=N(()=>W(d));var n=Fr(),r=R(n),i=e=>{var n=Fr();ni(R(n),()=>W(t),(e,t)=>{t(e,zi({get params(){return W(f)},get onRouteEvent(){return c()}},()=>W(p)))}),q(e,n)},a=e=>{var n=Fr();ni(R(n),()=>W(t),(e,t)=>{t(e,zi({get onRouteEvent(){return c()}},()=>W(p)))}),q(e,n)};Y(r,e=>{W(f)?e(i):e(a,-1)}),q(e,n)};Y(y,e=>{W(d)&&e(b)}),q(e,v),qe()}var Ji=[`bars`,`pace`,`zone`,`pie`,`fuel`,`resets`,`thresholds`],Yi=`codexometer.web.preferences.v1`,Xi={tab:`quota`,view:`bars`,selected:``,defaultDetail:0,layouts:[]};function Zi(){try{let e=JSON.parse(localStorage.getItem(Yi)||`null`);return!e||typeof e!=`object`?Xi:{tab:[`quota`,`sessions`,`usage`].includes(e.tab)?e.tab:`quota`,view:Ji.includes(e.view)?e.view:`bars`,selected:typeof e.selected==`string`?e.selected.slice(0,256):``,defaultDetail:+(e.defaultDetail===1),layouts:Array.isArray(e.layouts)?e.layouts.filter(e=>{if(!e||typeof e!=`object`)return!1;let t=e;return typeof t.id==`string`&&t.id.length<=256&&[0,1,2].includes(t.level)}).slice(-100):[]}}catch{return Xi}}var Qi=Zt(Zi());function $i(){let e=JSON.stringify(Qi);try{localStorage.setItem(Yi,e)}catch{}}function ea(){return Qi.tab===`quota`?`/quota/`+Qi.view:`/`+Qi.tab}function ta(e){return Qi.layouts.find(t=>t.id===e)?.level??Qi.defaultDetail}function na(e){Qi.defaultDetail=e,Qi.layouts=[]}function ra(e,t){Qi.layouts=[...Qi.layouts.filter(t=>t.id!==e),{id:e,level:Math.max(0,Math.min(2,t))}].slice(-100)}var $=Zt({data:null,connected:!1,error:``}),ia=e=>e==null?`Unavailable`:e.toLocaleString(`en-GB`),aa=e=>!e||String(e).startsWith(`0001-`)?`Not yet available`:new Date(typeof e==`number`?e*1e3:e).toLocaleString(`en-GB`),oa=`codexometer.web.session`,sa=class extends Error{},ca;async function la(e,t,n){if(!ca||!$.connected||!$.data?.control||$.data.sessionsError)throw Error(`Session controls unavailable. Check the live connection.`);return await ca(e,t,n)}function ua(){let e=new AbortController,t,n=``;ca=async(t,r,i)=>{let a=await fetch(`/api/control/`+t,{method:`POST`,headers:{Authorization:`Bearer ${n}`,"Content-Type":`application/json`},body:JSON.stringify(r),signal:AbortSignal.any([e.signal,AbortSignal.timeout(8e3),...i?[i]:[]]),cache:`no-store`});if(!a.ok)throw a.status===502?Error(`Outcome uncertain. Check Codex before taking another action; nothing was retried.`):new sa(`Action rejected, expired or changed. Refresh and check Codex; nothing was retried.`);return a.json()};try{n=sessionStorage.getItem(oa)||``}catch{}let r=location.hash,i=r.startsWith(`#pair=`)?r.slice(6):``;(i||!r||r===`#`)&&(history.replaceState(null,``,`/#`+ea()),window.dispatchEvent(new HashChangeEvent(`hashchange`)));async function a(){if(i)try{let t=await fetch(`/api/pair`,{method:`POST`,headers:{"Content-Type":`application/json`},body:JSON.stringify({secret:i}),signal:e.signal});if(!t.ok)throw Error(`Pairing link expired or already used. Restart --web for a fresh link.`);n=(await t.json()).token;try{sessionStorage.setItem(oa,n)}catch{}}catch(t){e.signal.aborted||($.error=t instanceof Error?t.message:`Pairing failed`);return}if(!n){$.error=`Open the private pairing link printed by codexometer --web in your terminal.`;return}await o()}async function o(){try{let t=await fetch(`/api/events`,{headers:{Authorization:`Bearer ${n}`},signal:e.signal,cache:`no-store`});if(t.status===401){try{sessionStorage.removeItem(oa)}catch{}$.error=`Browser session expired. Restart --web and open its new pairing link.`;return}if(!t.ok||!t.body)throw Error(`Live connection unavailable`);let r=t.body.getReader(),i=new TextDecoder,a=``;for(;!e.signal.aborted;){let{done:e,value:t}=await r.read();if(e)break;a+=i.decode(t,{stream:!0});let n;for(;(n=a.indexOf(` - -`))>=0;){let e=a.slice(0,n);a=a.slice(n+2),e.startsWith(`data: `)&&($.data=JSON.parse(e.slice(6)),$.connected=!0,$.error=``)}}}catch{}finally{$.connected=!1}e.signal.aborted||($.error=`Connection lost — showing last observation. Reconnecting…`,t=setTimeout(o,2500))}return a(),()=>{ca=void 0,e.abort(),clearTimeout(t),$.connected=!1}}We();var da=K(`

Quota refresh failed. Policy state is based on the last successful - observation.

`),fa=K(`

`),pa=K(`

The longest Codex quota window selects one active model profile. ASK - creates a per-session review; AUTO applies the profile on the next - eligible check.

`,1),ma=K(`

No threshold-based model steps were configured at launch.

`);function ha(e,t){Ke(t,!1),Pi();var n=Fr(),r=R(n),i=e=>{var t=pa(),n=R(t),r=z(n),i=B(n,2),a=e=>{q(e,da())};Y(i,e=>{$.data.quotaError&&e(a)});var o=B(i,2),s=L(o),c=z(s),l=B(s,4);Z(l,5,()=>$.data.thresholds,X,(e,t)=>{var n=fa();let r;var i=L(n),a=z(i),o=B(i,2),s=L(o),c=z(s,!0),l=z(B(s,2));A(o);var u=B(o,2),d=z(u,!0),f=z(B(u,2));A(n),V((e,i)=>{r=ci(n,1,``,null,r,{active:W(t).state===`ACTIVE`,next:W(t).state===`NEXT`}),J(a,`${W(t).threshold??``}%`),J(c,W(t).model),J(l,`${e??``} REASONING // ${i??``} - SPEED`),J(d,W(t).mode),J(f,`${W(t).state??``}${W(t).state===`NEXT`?` // ${W(t).remaining||0} PP TO GO`:``}`)},[()=>W(t).effort.toUpperCase(),()=>W(t).speed.toUpperCase()]),q(e,n)}),A(l),A(o),V(e=>{J(r,`MODEL STEP POLICY // OBSERVED ${e??``}`),J(c,`THRESHOLDS // ${$.data.thresholds.length??``} CONFIGURED`)},[()=>aa($.data.quotaAt)]),q(e,t)},a=e=>{q(e,ma())};Y(r,e=>{$.data?.thresholds?.length?e(i):e(a,-1)}),q(e,n),qe()}var ga=Nr(` `,1),_a=Nr(` `,1),va=K(` `),ya=K(`
Quota observations
Observed atPeriod elapsedConsumedTrail segment
`),ba=K(`


Observed quota - path, not individual session usage. Gaps are not interpolated. Expand the - observation table for times, positions and gaps.

OBSERVATION TABLE
`,1),xa=K(`
CONSUMPTIONQUOTA PERIOD ELAPSED


`,1);function Sa(e,t){let n=Ir();Ke(t,!0);let r=Bi(t,`trail`,19,()=>[]),i=F(!1),a=[0,25,50,75,100],o=F(400),s=F(240),c=N(()=>W(o)-24),l=N(()=>W(s)-48),u=N(()=>Math.max(1,W(c)-48)),d=N(()=>Math.max(1,W(l)-20)),f=N(()=>48+Math.max(0,Math.min(100,t.elapsed))/100*W(u)),p=N(()=>W(l)-Math.max(0,Math.min(100,t.used))/100*W(d)),m=N(()=>t.used-t.elapsed),h=N(()=>r().map((e,t)=>`${t===0||e.break?`M`:`L`}${48+e.elapsed/100*W(u)} ${W(l)-e.used/100*W(d)}`).join(` `));var g=xa(),_=R(g),v=L(_),y=L(v),b=z(y),x=B(y),S=B(x);Z(S,17,()=>a,X,(e,t)=>{var n=ga(),r=R(n),i=B(r),a=B(i),o=z(a),s=B(a),f=z(s);V(()=>{Q(r,`x1`,48+W(t)/100*W(u)),Q(r,`x2`,48+W(t)/100*W(u)),Q(r,`y2`,W(l)),Q(i,`y1`,W(l)-W(t)/100*W(d)),Q(i,`x2`,W(c)),Q(i,`y2`,W(l)-W(t)/100*W(d)),Q(a,`x`,48+W(t)/100*W(u)),Q(a,`y`,W(l)+20),J(o,`${W(t)??``}%`),Q(s,`y`,W(l)+4-W(t)/100*W(d)),J(f,`${W(t)??``}%`)}),q(e,n)});var ee=B(S),C=B(ee),w=B(C),T=e=>{var t=_a(),n=R(t),i=B(n),a=z(L(i));A(i),V(e=>{Q(n,`d`,W(h)),Q(i,`cx`,48+r()[0].elapsed/100*W(u)),Q(i,`cy`,W(l)-r()[0].used/100*W(d)),J(a,`First observation: ${e??``}`)},[()=>aa(r()[0].at)]),q(e,t)};Y(w,e=>{r().length&&e(T)});var te=B(w,2),E=B(te),ne=B(E),re=z(L(ne));A(ne),A(v),A(_);var ie=B(_,2),D=L(ie),ae=z(B(D,3),!0);A(ie);var oe=B(ie,2),se=e=>{var t=ba(),a=R(t),o=L(a);Te(2),A(a);var s=B(a,2),c=B(L(s),2),l=e=>{var t=ya(),n=L(t),i=B(L(n),2);Z(i,21,r,X,(e,t,n)=>{var r=va(),i=L(r),a=L(i),o=z(a,!0);A(i);var s=B(i),c=z(s),l=B(s),u=z(l),d=z(B(l),!0);A(r),V((e,r)=>{Q(a,`datetime`,W(t).at),J(o,e),J(c,`${r??``}%`),J(u,`${W(t).used??``}%`),J(d,n===0?`First observation`:W(t).break?`Gap before this observation`:`Connected to previous observation`)},[()=>aa(W(t).at),()=>W(t).elapsed.toFixed(1)]),q(e,r)}),A(i),A(n),A(t),q(e,t)};Y(c,e=>{W(i)&&e(l)}),A(s),V(e=>{Q(a,`id`,n+`-trail-summary`),J(o,`○ START ${e??``} // ${r().length??``} - ${r().length===1?`OBSERVATION`:`OBSERVATIONS`}`)},[()=>aa(r()[0].at)]),Ni(`open`,`toggle`,s,e=>I(i,e),()=>W(i)),q(e,t)};Y(oe,e=>{r().length&&e(se)}),V((e,i,a,m)=>{Q(v,`viewBox`,`0 0 ${W(o)} ${W(s)}`),Q(v,`aria-describedby`,r().length?n+`-trail-summary`:void 0),Q(v,`aria-label`,e),Q(b,`id`,n),Q(x,`width`,W(u)),Q(x,`height`,W(d)),Q(x,`fill`,`url(#${n})`),Q(ee,`d`,`M48 20 V${W(l)} H${W(c)}`),Q(C,`y1`,W(l)),Q(C,`x2`,W(c)),Q(te,`x`,48+W(u)/2),Q(te,`y`,W(s)-5),Q(E,`cx`,W(f)),Q(E,`cy`,W(p)),Q(ne,`cx`,W(f)),Q(ne,`cy`,W(p)),J(re,`${t.used??``}% consumed // ${i??``}% of period elapsed`),J(D,`${t.used??``}% USED // ${a??``}% TIME ELAPSED`),J(ae,m)},[()=>`Consumption zone: ${t.used}% consumed, ${t.elapsed.toFixed(1)}% of quota period elapsed. ${W(m)>0?`Above`:W(m)<0?`Below`:`On`} the steady-consumption line.`,()=>t.elapsed.toFixed(1),()=>t.elapsed.toFixed(1),()=>Math.abs(W(m))<.05?`ON PACE`:W(m)>0?`ABOVE THE LINE — CONSUMING FASTER THAN TIME`:`BELOW THE LINE — WITHIN PACE`]),Mi(_,`clientWidth`,e=>I(o,e)),Mi(_,`clientHeight`,e=>I(s,e)),q(e,g),qe()}var Ca=K(` `),wa=K(`

Quota refresh failed. Values below are the last successful observation.

`),Ta=K(`

Expiry details unavailable. No listed expiry does not mean no expiry.

`),Ea=K(`

`),Da=K(`

Read-only preview. Use the terminal to redeem a reset.

The backend may return only some credits. This list does not establish - redemption order.

`),Oa=K(` `,1),ka=Nr(``),Aa=Nr(``),ja=K(`
`),Ma=K(`

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

`),Na=K(`
▼
−100 // OVER BUDGET+100 // HEADROOM

`,1),Pa=K(`

Cycle duration unavailable — pace cannot be calculated.

`),Fa=K(`
EMPTYFULL
`),Ia=K(`

`,1),La=K(`
`,1),Ra=K(`

`),za=K(`

`),Ba=K(`

No quota windows reported yet.

`),Va=K(`
`,1),Ha=K(`

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

`,1),Ua=K(` `,1);function Wa(e,t){Ke(t,!0);let n=Bi(t,`params`,19,()=>({})),r=N(()=>Ji.filter(e=>e!==`thresholds`||!!$.data?.thresholds?.length)),i=F(Zt(Date.now())),a=N(()=>W(r).includes(n().view||``)?n().view:`bars`);Vi(()=>{let e=setInterval(()=>I(i,Date.now(),!0),1e3);return()=>clearInterval(e)});function o(e){return!e.duration||e.duration<=0||!e.reset?null:Math.max(0,Math.min(100,100*(1-(e.reset*1e3-W(i))/(e.duration*6e4))))}bn(()=>{Qi.view=W(a)});function s(e){let t=e/100*Math.PI*2;return`M60 60 L60 14 A46 46 0 ${+(e>50)} 1 ${60+46*Math.sin(t)} ${60-46*Math.cos(t)} Z`}var c=Ua(),l=R(c);Z(l,21,()=>W(r),X,(e,t)=>{var n=Ca();let r;var i=z(n,!0);V(e=>{Q(n,`href`,`#/quota/`+W(t)),Q(n,`aria-current`,W(a)===W(t)?`page`:void 0),r=ci(n,1,``,null,r,{active:W(a)===W(t)}),J(i,e)},[()=>W(t)===`pace`?`CONSUMPTION PACE`:W(t)===`zone`?`CONSUMPTION ZONE`:W(t)===`fuel`?`FUEL TANK`:W(t).toUpperCase()]),q(e,n)}),A(l);var u=B(l,2),d=e=>{var t=Ha(),n=R(t),r=e=>{q(e,wa())};Y(n,e=>{$.data.quotaError&&e(r)});var i=B(n,2),c=z(i),l=B(i,2),u=e=>{ha(e,{})},d=e=>{var t=Da(),n=L(t),r=z(n),i=B(n,4),a=e=>{q(e,Ta())};Y(i,e=>{$.data.credits.length||e(a)}),Z(B(i,2),17,()=>$.data.credits,X,(e,t)=>{var n=Ea(),r=L(n),i=z(r),a=z(B(r,2),!0);A(n),V(e=>{J(i,`${(W(t).title||`Quota reset`)??``} // ${W(t).status??``}`),J(a,e)},[()=>W(t).expiryKnown?W(t).expires?`EXPIRES `+aa(W(t).expires):`Does not expire`:`Expiry information unavailable`]),q(e,n)}),Te(2),A(t),V(()=>J(r,`RESET INVENTORY // ${$.data.creditCount??``} AVAILABLE`)),q(e,t)},f=e=>{var t=Va(),n=R(t);let r;Z(n,21,()=>$.data.meters,X,(e,t)=>{let n=N(()=>o(W(t))),r=N(()=>W(n)===null?null:W(n)-W(t).used);var i=za(),c=L(i),l=z(c,!0),u=B(c,2),d=L(u),f=e=>{var n=Oa(),r=R(n),i=z(r),a=z(B(r));V(()=>{J(i,`FREE ${100-W(t).used}%`),J(a,`USED ${W(t).used??``}%`)}),q(e,n)},p=e=>{var n=Oa(),r=R(n),i=z(r),a=z(B(r));V(()=>{J(i,`USED ${W(t).used??``}%`),J(a,`FREE ${100-W(t).used}%`)}),q(e,n)};Y(d,e=>{W(a)===`fuel`?e(f):e(p,-1)}),A(u);var m=B(u,2);let h;var g=L(m),_=e=>{var n=ja(),r=L(n),i=B(L(r)),a=e=>{q(e,ka())},o=e=>{var n=Aa();V(e=>Q(n,`d`,e),[()=>s(W(t).used)]),q(e,n)};Y(i,e=>{W(t).used>=100?e(a):W(t).used>0&&e(o,1)}),A(r),A(n),V(()=>Q(r,`aria-label`,`${W(t).used}% quota used`)),q(e,n)},v=e=>{var r=Fr(),i=R(r),a=e=>{{let r=N(()=>W(t).trail||[]);Sa(e,{get used(){return W(t).used},get elapsed(){return W(n)},get trail(){return W(r)}})}},o=e=>{q(e,Ma())};Y(i,e=>{W(n)===null?e(o,-1):e(a)}),q(e,r)},y=e=>{var t=Fr(),n=R(t),i=e=>{var t=Na(),n=R(t),i=B(L(n),2);let a;A(n);var o=B(n,4),s=L(o),c=z(B(s),!0);A(o),V(e=>{a=ui(i,``,a,{left:`${(W(r)+100)/2}%`}),J(s,`${W(r)>=0?`+`:``}${e??``} PP `),J(c,W(r)>=0?`WITHIN PACE`:`USING FASTER THAN TIME`)},[()=>W(r).toFixed(1)]),q(e,t)},a=e=>{q(e,Pa())};Y(n,e=>{W(r)===null?e(a,-1):e(i)}),q(e,t)},b=e=>{var r=La(),i=R(r),o=L(i);let s;A(i);var c=B(i,2),l=e=>{q(e,Fa())};Y(c,e=>{W(a)===`fuel`&&e(l)});var u=B(c,2),d=e=>{var t=Ia(),r=R(t),i=z(r),o=B(r,2),s=L(o);let c;A(o),V(e=>{J(i,`RESET CYCLE // ${e??``}% ELAPSED`),c=ui(s,``,c,{width:`${W(a)===`fuel`?100-W(n):W(n)}%`})},[()=>Math.floor(W(n))]),q(e,t)};Y(u,e=>{W(n)!==null&&e(d)}),V(()=>{Q(i,`aria-label`,W(a)===`fuel`?`Fuel remaining`:`Quota used`),Q(i,`aria-valuenow`,W(a)===`fuel`?100-W(t).used:W(t).used),s=ui(o,``,s,{width:`${W(a)===`fuel`?100-W(t).used:W(t).used}%`})}),q(e,r)};Y(g,e=>{W(a)===`pie`?e(_):W(a)===`zone`?e(v,1):W(a)===`pace`?e(y,2):e(b,-1)}),A(m);var x=B(m,2),S=z(x),ee=B(x,2),C=e=>{var n=Ra(),r=z(n,!0);V(()=>J(r,W(t).details)),q(e,n)};Y(ee,e=>{W(t).details&&e(C)}),A(i),V(e=>{J(l,W(t).name),h=ci(m,1,`meter-graphic`,null,h,{"bar-graphic":W(a)===`bars`||W(a)===`fuel`}),J(S,`RESETS // ${e??``}`)},[()=>aa(W(t).reset)]),q(e,i)}),A(n);var i=B(n,2),c=e=>{q(e,Ba())};Y(i,e=>{$.data.meters.length||e(c)}),V(()=>r=ci(n,1,`quota-grid`,null,r,{radial:W(a)===`pie`,zone:W(a)===`zone`})),q(e,t)};Y(l,e=>{W(a)===`thresholds`?e(u):W(a)===`resets`?e(d,1):e(f,-1)}),Te(2),V(e=>J(c,`QUOTA // OBSERVED ${e??``}`),[()=>aa($.data.quotaAt)]),q(e,t)};Y(u,e=>{$.data&&e(d)}),q(e,c),qe()}var Ga=K(`
`),Ka=K(`

`,1);function qa(e,t){Ke(t,!0);let n=Bi(t,`values`,19,()=>[]),r=Bi(t,`label`,3,`Token activity`),i=Bi(t,`capacity`,3,0),a=N(()=>Math.max(0,...n())),o=N(()=>i()>n().length?[...Array(i()-n().length).fill(0),...n()]:n());var s=Ka(),c=R(s),l=z(c),u=B(c,2);Z(u,21,()=>W(o),X,(e,t)=>{var n=Ga();let r;V((e,t)=>{Q(n,`title`,e),r=ui(n,``,r,{height:t})},[()=>W(t).toLocaleString(`en-GB`)+` tokens`,()=>`${100*W(t)/Math.max(1,W(a))}%`]),q(e,n)}),A(u),V((e,t)=>{J(l,`SCALE // 0 — ${e??``} TOKENS`),Q(u,`aria-label`,t)},[()=>W(a).toLocaleString(`en-GB`),()=>`${r()}. Peak ${W(a).toLocaleString(`en-GB`)} tokens.`]),q(e,s),qe()}var Ja=K(`

CURRENT PROFILE

MODEL / REASONING LEVEL / SPEED

 

PROPOSED PROFILE

MODEL / REASONING LEVEL / SPEED

 

Applied settings remain after Codexometer closes.

`,1),Ya=K(`

`),Xa=K(`

 
`,1),Za=K(`

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

`),Qa=K(`

Session controls temporarily unavailable. Check Codex for current state.

`),$a=K(`

Checking session controls…

`),eo=K(`

`),to=K(`
About browser controls

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

`),no=K(` `,1),ro=K(` `),io=K(`Grants permission beyond this one command. Check the scope - carefully.`),ao=K(``),oo=K(``),so=K(``),co=K(``),lo=K(` `,1),uo=K(`
  • `),fo=K(`
    View fixed choices

    Type one of these choices exactly. Your answer stays masked.

      `),po=K(` `,1),mo=K(`

      `,1),ho=K(``),go=K(`

      `,1),_o=K(`

      `);function vo(e,t){Ke(t,!0);let n=[],r=Bi(t,`observedCommand`,3,``),i=Bi(t,`review`,3,``),a=Bi(t,`suspended`,3,!1),o=Bi(t,`onProtectedChange`,3,e=>{}),s=F(null),c=F(Zt([])),l=F(null),u=F(``),d=F(0),f=F(Zt(Date.now())),p=F(!1),m=F(``),h=F(!1),g=F(!1),_=F(``);bn(()=>(o()(W(p)||W(c).some(e=>e.length>0)),()=>o()(!1)));let v=N(()=>$.data?.sessions.find(e=>e.id===t.session)?.status),y=N(()=>!$.connected||!!$.data?.sessionsError),b=N(()=>i()!==`profile`&&W(s)?.kind===`prompt`&&!W(s).questions?.length&&!!$.data?.profiles?.some(e=>e.session===t.session&&e.pending)),x=N(()=>!W(y)&&!W(g)&&!!W(s)?.id&&W(s).kind===`approval`&&W(_)!==W(s).id),S=N(()=>W(x)?W(s).command:r()),ee=N(()=>!!W(u)&&W(f)W(s)?.questions?.length?W(s).questions:[{text:`Follow-up message`,secret:!1,freeText:!0,options:[]}]),w=N(()=>W(s)?.kind===`approval`||W(s)?.kind===`profile`?W(l)!==null:W(c).length===W(C).length&&W(c).every((e,t)=>e.trim().length>0&&(W(C)[t].freeText||W(C)[t].options?.includes(e))));bn(()=>{(W(y)||a()||W(b)||W(f)>=W(d))&&I(u,``)});let T=new AbortController;Vi(()=>{let e,n=setInterval(()=>{I(f,Date.now(),!0)},1e3);async function r(){try{let e=await la(`offer`,{session:t.session,...i()?{review:i()}:{}},T.signal);if(T.signal.aborted)return;I(g,!1),W(s)?.id!==e.id&&(I(u,``),I(l,null),I(c,Array(Math.max(1,e.questions?.length||0)).fill(``),!0)),I(s,e,!0),W(h)&&e.id&&e.id!==W(_)&&(I(m,``),I(h,!1))}catch{T.signal.aborted||(I(s,null),I(g,!0),I(u,``))}finally{T.signal.aborted||(e=setTimeout(r,2e3))}}return r(),()=>{T.abort(),clearTimeout(e),clearInterval(n)}});async function te(){if(!W(s)?.id||W(p)||W(y)||a()||W(b)||!W(w))return;let e=W(s).id;I(p,!0),I(m,``),I(h,!1),I(u,``);try{let n=await la(`prepare`,{session:t.session,...i()?{review:i()}:{},offer:e,...W(s).kind===`approval`||W(s).kind===`profile`?{choice:W(l)}:{answers:[...W(c)]}},T.signal);W(s)?.id===e&&!T.signal.aborted&&!W(y)&&!a()&&!W(b)&&(I(u,n.confirmation,!0),I(d,Date.parse(n.expires),!0))}catch(e){T.signal.aborted||I(m,e instanceof Error?e.message:`Unable to prepare action.`,!0)}finally{I(p,!1)}}async function E(){if(!W(s)?.id||W(p)||a()||W(b)||!W(ee))return;let e=W(s).id,n=W(s).kind,r=W(u);I(u,``),I(p,!0),I(_,e,!0);try{await la(`commit`,{session:t.session,...i()?{review:i()}:{},offer:e,confirmation:r},T.signal),I(h,!0),I(m,n===`profile`?`Profile decision completed.`:n===`approval`?`Decision sent.`:`Text sent.`,!0)}catch(e){I(m,e instanceof sa?e.message:`Outcome uncertain. Check Codex before taking another action; nothing was retried.`,!0)}finally{I(p,!1),I(c,[],!0),I(l,null)}}var ne=Fr(),re=R(ne),ie=e=>{var r=_o(),a=L(r),o=z(a,!0),b=B(a,2),T=e=>{var t=Ja(),n=R(t),r=z(n),i=B(n,6),a=z(i,!0),o=z(B(i,6),!0);Te(2),V(()=>{J(r,`Your ${W(s).profile.threshold??``}% quota threshold has been reached. Review - the profile for subsequent turns.`),J(a,W(s).profile.current),J(o,W(s).profile.proposed)}),q(e,t)};Y(b,e=>{W(s)?.profile&&!W(y)&&!W(g)&&e(T)});var ne=B(b,2),re=e=>{var t=Ya();let n;var r=z(t,!0);V(()=>{n=ci(t,1,`svelte-1oupzfc`,null,n,{notice:!W(h),sent:W(h)}),J(r,W(m))}),q(e,t)};Y(ne,e=>{W(m)&&e(re)});var ie=B(ne,2),D=e=>{var t=Xa(),n=R(t),r=z(n,!0),i=z(B(n,2),!0);V(()=>{J(r,W(x)?`EXACT COMMAND`:`LAST OBSERVED COMMAND`),J(i,W(S))}),q(e,t)},ae=e=>{q(e,Za())};Y(ie,e=>{W(S)?e(D):i()!==`profile`&&W(v)===`APPROVAL NEEDED`&&!W(h)&&e(ae,1)});var oe=B(ie,2),se=e=>{q(e,Qa())},ce=e=>{q(e,$a())},le=e=>{var t=no(),n=R(t),r=e=>{var t=eo(),n=z(t,!0);V(e=>J(n,e),[()=>W(v)===`WORKING`?`Codex is working — nothing to respond to.`:[`APPROVAL NEEDED`,`INPUT NEEDED`].includes(W(v)||``)?`Respond in Codex for this request.`:`Nothing needs a response right now.`]),q(e,t)};Y(n,e=>{(W(v)===`WORKING`||!W(h)&&!W(p))&&e(r)});var i=B(n,2),a=e=>{q(e,to())},o=N(()=>[`APPROVAL NEEDED`,`INPUT NEEDED`].includes(W(v)||``)&&!W(h));Y(i,e=>{W(o)&&e(a)}),q(e,t)},ue=e=>{var r=go(),a=R(r),o=z(a),m=B(a,2),h=L(m),g=z(h,!0),_=B(h,2),v=e=>{var r=Fr();Z(R(r),17,()=>W(s).choices||[],X,(e,r,a)=>{var o=ao(),s=L(o);xi(s),s.value=s.__value=a;var c=B(s),u=B(c),d=e=>{var t=ro(),n=z(t,!0);V(()=>J(n,W(r).detail)),q(e,t)};Y(u,e=>{W(r).detail&&e(d)});var f=B(u,2),p=e=>{q(e,io())};Y(f,e=>{W(r).persistent&&e(p)}),A(o),V(()=>{Q(s,`name`,`decision-`+t.session+`-`+i()),J(c,` ${W(r).label??``} `)}),Di(n,[],s,()=>W(l),e=>I(l,e)),q(e,o)}),q(e,r)},b=e=>{var t=Fr();Z(R(t),17,()=>W(C),X,(e,t,n)=>{var r=po(),i=R(r),a=L(i),o=B(a),s=e=>{var t=oo();xi(t),Ti(t,()=>W(c)[n],e=>W(c)[n]=e),q(e,t)},l=e=>{var r=co(),i=L(r);i.value=i.__value=``,Z(B(i),17,()=>W(t).options||[],X,(e,t)=>{var n=so(),r=z(n,!0),i={};V(()=>{J(r,W(t)),i!==(i=W(t))&&(n.value=(n.__value=i)??``)}),q(e,n)}),A(r),mi(r),hi(r,()=>W(c)[n],e=>W(c)[n]=e),q(e,r)},u=e=>{var r=lo(),i=R(r);rt(i);var a=B(i,2),o=e=>{var n=eo(),r=z(n);V(e=>J(r,`Suggested answers: ${e??``}`),[()=>W(t).options.join(` · `)]),q(e,n)};Y(a,e=>{W(t).options?.length&&e(o)}),Ti(i,()=>W(c)[n],e=>W(c)[n]=e),q(e,r)};Y(o,e=>{W(t).secret?e(s):W(t).freeText?e(u,-1):e(l,1)}),A(i);var d=B(i,2),f=e=>{var n=fo(),r=B(L(n),4);Z(r,21,()=>W(t).options||[],X,(e,t)=>{var n=uo(),r=z(n,!0);V(()=>J(r,W(t))),q(e,n)}),A(r),A(n),q(e,n)};Y(d,e=>{W(t).secret&&!W(t).freeText&&e(f)}),V(()=>J(a,`${W(t).text??``} `)),q(e,r)}),q(e,t)};Y(_,e=>{W(s).kind===`approval`||W(s).kind===`profile`?e(v):e(b,-1)}),A(m);var x=B(m,2),S=e=>{var t=mo(),n=R(t),r=z(n),i=B(n,2),a=z(i),o=B(i,2);V(e=>{J(r,`Check the target and ${W(s).kind===`profile`?`profile above`:W(s).kind===`approval`?`exact command and permission scope`:`message above`}. This will send to Codex; it may start work - using your quota. Confirmation expires in ${e??``}s.`),i.disabled=W(p)||W(y),J(a,`CONFIRM ${(W(s).kind===`approval`||W(s).kind===`profile`?W(s).choices?.[W(l)]?.label:`SEND`)??``}`)},[()=>Math.max(0,Math.ceil((W(d)-W(f))/1e3))]),G(`click`,i,E),G(`click`,o,()=>{I(u,``)}),q(e,t)},T=e=>{var t=ho(),n=z(t,!0);V(()=>{t.disabled=W(p)||W(y)||!W(w),J(n,W(p)?`SENDING…`:`REVIEW BEFORE SENDING`)}),G(`click`,t,te),q(e,t)};Y(x,e=>{W(ee)?e(S):e(T,-1)}),V(()=>{J(o,`TARGET // ${W(s).thread??``} // ${(W(s).directory||`Directory unavailable`)??``}`),m.disabled=W(p)||W(ee)||W(y),J(g,W(s).kind===`approval`||W(s).kind===`profile`?`Choose a decision`:`Reply to this session`)}),q(e,r)};Y(oe,e=>{W(y)||W(g)?e(se):W(s)?W(s).id?W(_)!==W(s).id&&e(ue,3):e(le,2):e(ce,1)}),A(r),V(()=>J(o,i()===`profile`?`QUOTA THRESHOLD`:`SESSION CONTROL // EXPERIMENTAL`)),q(e,r)};Y(re,e=>{W(b)||e(ie)}),q(e,ne),qe()}wr([`click`]);var yo=K(`
      `);function bo(e,t){Ke(t,!0);let n=Bi(t,`active`,3,!1),r=N(()=>t.session.text||t.session.contextKind===`LAST ACTIVITY`&&t.session.workingCommand||``),i=N(()=>[`LAST REPLY`,`LAST ACTIVITY`].includes(t.session.contextKind)&&!!W(r).trim()),a=F(!1),o=F(``),s=F(!1);bn(()=>{if(!W(s))return;let e=setTimeout(()=>{I(s,!1)},150);return()=>clearTimeout(e)}),bn(()=>{W(r),t.session.status,I(o,``)});async function c(){if(!W(i)||W(a))return;let e=W(r);I(a,!0),I(s,!0),I(o,``);try{await navigator.clipboard.writeText(e),W(r)===e&&I(o,`Copied.`)}catch{W(r)===e&&I(o,`Clipboard unavailable. Select the visible text and copy it manually.`)}finally{I(a,!1)}}function l(e){!n()||!W(i)||e.defaultPrevented||e.repeat||e.ctrlKey||e.metaKey||e.altKey||e.key.toLowerCase()!==`c`||e.target instanceof Element&&e.target.closest(`input, textarea, select, [contenteditable]`)||(e.preventDefault(),c())}var u=Fr();Cr(`keydown`,en,l);var d=R(u),f=e=>{var t=yo(),n=L(t),r=z(n,!0),i=B(n,2);let l;A(t),V(()=>{J(r,W(o)),i.disabled=W(a),l=ci(i,1,`svelte-543j00`,null,l,{flashed:W(s)})}),G(`click`,i,c),q(e,t)};Y(d,e=>{W(i)&&e(f)}),q(e,u),qe()}wr([`click`]);var xo=K(`

      CONTEXT

       

      `,1),So=K(`

      `),Co=K(`

      `),wo=K(`
       
      `),To=K(`

       
      `,1),Eo=K(`
       
      `),Do=K(`

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

      `),Oo=K(`

      `,1),ko=K(`

      `),Ao=K(` `,1),jo=K(`
      `),Mo=K(`

      Session connection or refresh unavailable. Context and telemetry may be - stale.

      `),No=K(`

      Some quota profile checks are unavailable. Only sessions with freshly - verified quota and settings can be updated; previous outcome notices remain - visible.

      `),Po=K(` `),Fo=K(` `),Io=K(``),Lo=K(`

      Read only — reply or approve in Codex.

      `),Ro=K(`

      `),zo=K(`QUOTA THRESHOLD // REVIEW PROFILE ↗`),Bo=K(`
      `),Vo=K(`
      `),Ho=K(`

      This session is no longer in the current observation. Return to sessions.

      `),Uo=K(`

      `),Wo=K(``),Go=K(`

      `),Ko=K(` `),qo=K(`

      `),Jo=K(`QUOTA THRESHOLD // REVIEW PROFILE ↗`),Yo=K(` `,1),Xo=K(`

      `),Zo=K(`

      TOKEN ACTIVITY // 30 SECOND SAMPLES

      `),Qo=K(`

      TOKENS

      FULL DETAIL →
      `),$o=K(`

      No locally observed sessions yet. Keep Codex running alongside - Codexometer.

      `),es=K(`

      ↑ ↓ SELECT SESSION // ← LESS DETAIL // → MORE DETAIL // ESC BACK

      `,1),ts=K(`

      SESSION TOTALS

      `,1);function ns(e,t){Ke(t,!0);let n=(e,t=f,n,r)=>{let i=ht(()=>g(n?.(),!0)),a=ht(()=>g(r?.(),!1));var o=Ao(),s=R(o),c=e=>{var n=xo(),r=z(B(R(n),2),!0);Te(2),V(()=>J(r,t().approvalContext)),q(e,n)};Y(s,e=>{W(a)&&t().contextKind===`APPROVAL REQUEST`&&t().approvalContext&&e(c)});var l=B(s,2),u=e=>{var n=So();let r;var i=z(n,!0);V(e=>{r=ci(n,1,`attention-note`,null,r,{inferred:t().status===`CHECK SESSION`}),J(i,e)},[()=>b(t())]),q(e,n)},p=N(()=>b(t())&&(!W(a)||W(d)||t().status===`CHECK SESSION`));Y(l,e=>{W(p)&&e(u)});var m=B(l,2),h=e=>{var n=Co(),r=z(n,!0);V(()=>J(r,t().contextKind||`LAST ACTIVITY`)),q(e,n)};Y(m,e=>{W(i)&&e(h)});var _=B(m,2),v=e=>{var n=wo(),r=z(n,!0);V(()=>J(r,t().text||`No session context available.`)),q(e,n)};Y(_,e=>{(t().text||!t().workingCommand)&&e(v)});var y=B(_,2),x=e=>{var n=To(),r=B(R(n),2),i=L(r),a=B(i),o=e=>{var n=Pr();V(()=>J(n,`// +${(t().runningCommands||0)-+(t().commandStatus===`running`)} RUNNING`)),q(e,n)};Y(a,e=>{!W(d)&&(t().runningCommands||0)>+(t().commandStatus===`running`)&&e(o)});var s=B(a,2),c=e=>{q(e,Pr(`+`))};Y(s,e=>{t().runningLimited&&e(c)}),A(r);var l=z(B(r,2),!0);V(e=>{J(i,`COMMAND // ${e??``} `),J(l,t().workingCommand)},[()=>W(d)?`UNKNOWN`:(t().commandStatus||`unknown`).toUpperCase()]),q(e,n)};Y(y,e=>{t().workingCommand&&e(x)});var S=B(y,2),ee=e=>{var n=Oo(),r=B(R(n),2),i=z(r,!0),a=B(r,2),o=e=>{var n=Eo(),r=z(n,!0);V(()=>J(r,t().command)),q(e,n)},s=e=>{q(e,Do())};Y(a,e=>{t().command?e(o):e(s,-1)}),V(()=>J(i,W(d)||t().status!==`APPROVAL NEEDED`?`LAST OBSERVED COMMAND`:$.data?.control?`COMMAND REQUEST`:`COMMAND TO APPROVE IN CODEX`)),q(e,n)};Y(S,e=>{(!W(a)||!$.data?.control)&&(t().command||t().status===`APPROVAL NEEDED`)&&e(ee)});var C=B(S,2),w=e=>{var n=ko(),r=z(n);V(()=>J(r,`CONTEXT SOURCE // ${(t().source||`LOCAL`)??``} // OBSERVATION`)),q(e,n)};Y(C,e=>{W(a)||e(w)}),q(e,o)},r=Bi(t,`params`,19,()=>({})),i=N(()=>$.data?.sessions||[]),a=N(()=>$.data?.control&&$.data.profiles||[]),o=F(!1);function s(e,t){if(r().id&&r().id!==t&&W(o)){e.preventDefault();return}h(t)}bn(()=>{let e=r().id;e&&mr(()=>{Qi.selected=e,ra(e,2)})});let c=N(()=>W(i).find(e=>e.id===r().id)),l=N(()=>new URLSearchParams(Wi.querystring).get(`review`)===`profile`&&W(a).some(e=>e.session===r().id&&e.pending)),u=N(()=>W(i).some(e=>e.id===Qi.selected)?Qi.selected:W(i)[0]?.id),d=N(()=>!$.connected||!!$.data?.sessionsError),p=N(()=>[[`OBSERVED TOKENS`,ia(W(i).reduce((e,t)=>e+t.tokens,0))],[`LISTED SESSIONS`,ia(W(i).length)],...[[`WORKING`,`WORKING`],[`AWAITING APPROVAL`,`APPROVAL NEEDED`],[`AWAITING INPUT`,`INPUT NEEDED`],[`CHECK · INFERRED`,`CHECK SESSION`]].map(([e,t])=>[e,W(d)?`—`:ia(W(i).filter(e=>e.status===t).length)])]),m=N(()=>W(i).filter(e=>[`INPUT NEEDED`,`APPROVAL NEEDED`,`CHECK SESSION`].includes(e.status)));function h(e){Qi.selected=e,dr().then(()=>document.querySelector(`.session-row.selected`)?.scrollIntoView({block:`nearest`}))}function v(e,t){if(h(e),r().id){t<0&&(ra(e,2),location.hash=`/sessions`);return}let n=ta(e)+t;n>2?location.hash=`/sessions/`+encodeURIComponent(e):ra(e,n)}function y(e){let t=e.target;if(!(e.altKey||e.ctrlKey||e.metaKey||e.shiftKey||t.closest(`input, textarea, select, [contenteditable="true"]`))){if(e.key===`Escape`&&r().id)e.preventDefault(),v(r().id,-1);else if([`ArrowLeft`,`ArrowRight`].includes(e.key)&&(r().id||W(u)))e.preventDefault(),v(r().id||W(u),e.key===`ArrowLeft`?-1:1);else if(!r().id&&[`ArrowUp`,`ArrowDown`].includes(e.key)&&W(i).length){e.preventDefault();let t=W(i).findIndex(e=>e.id===W(u));h(W(i)[Math.max(0,Math.min(W(i).length-1,t+(e.key===`ArrowUp`?-1:1)))].id)}}}function b(e){return W(d)?`Last observation only — refresh unavailable. Check Codex for current state.`:e.status===`CHECK SESSION`?`INFERRED INACTIVITY — a quiet session, not a confirmed input or approval request. Local tools may still be running.`:$.data?.control&&[`APPROVAL NEEDED`,`INPUT NEEDED`].includes(e.status)?`Open full detail for supported live controls; otherwise reply in Codex.`:e.status===`APPROVAL NEEDED`?`OBSERVED APPROVAL SIGNAL — approve or decline in Codex.`:e.status===`INPUT NEEDED`?`OBSERVED INPUT SIGNAL — reply in Codex.`:e.status===`TURN COMPLETE`?`OBSERVED TURN COMPLETION — informational, not an approval request.`:``}var x=ts();Cr(`keydown`,en,y);var S=R(x),ee=z(B(L(S),2));A(S);var C=B(S,2);let w;Z(C,21,()=>W(p),X,(e,t)=>{var n=N(()=>_(W(t),2));let r=()=>W(n)[0],i=()=>W(n)[1];var a=jo(),o=L(a),s=z(o,!0),c=z(B(o,2),!0);A(a),V(()=>{J(s,r()),J(c,i())}),q(e,a)}),A(C);var T=B(C,2),te=z(T),E=B(T,2),ne=e=>{q(e,Mo())};Y(E,e=>{W(d)&&e(ne)});var re=B(E,2),ie=e=>{q(e,No())};Y(re,e=>{$.data?.profileError&&e(ie)});var D=B(re,2),ae=e=>{var t=Io(),n=L(t);Z(n,17,()=>W(m),X,(e,t)=>{var n=Po();let r;var i=z(n);V((e,a)=>{r=ci(n,1,`button`,null,r,{approval:W(t).status===`APPROVAL NEEDED`}),Q(n,`href`,e),J(i,`${W(t).status??``} - ${a??``} // ${(W(t).name||W(t).directory||W(t).id)??``}`)},[()=>`#/sessions/`+encodeURIComponent(W(t).id),()=>Array.from(W(t).id).slice(-5).join(``).toUpperCase()]),G(`click`,n,()=>h(W(t).id)),q(e,n)}),Z(B(n,2),17,()=>W(a).filter(e=>e.pending&&W(i).some(t=>t.id===e.session)),X,(e,t)=>{var n=Fo(),a=z(n);V((e,i,s)=>{Q(n,`title`,r().id&&r().id!==W(t).session&&W(o)?`Finish sending or clear your current draft before switching sessions.`:`Review this session’s quota threshold`),Q(n,`href`,e),J(a,`QUOTA THRESHOLD ${i??``} // ${s??``}`)},[()=>`#/sessions/`+encodeURIComponent(W(t).session)+`?review=profile`,()=>Array.from(W(t).session).slice(-5).join(``).toUpperCase(),()=>W(i).find(e=>e.id===W(t).session)?.name||W(i).find(e=>e.id===W(t).session)?.directory||W(t).session]),G(`click`,n,e=>s(e,W(t).session)),q(e,n)}),A(t),q(e,t)},oe=N(()=>(W(m).length||W(a).some(e=>e.pending))&&!W(d));Y(D,e=>{W(oe)&&e(ae)});var se=B(D,2),ce=e=>{var t=Fr(),i=R(t),s=e=>{var t=Vo(),i=L(t),s=L(i),u=L(s);let f;var p=B(u);A(s);var m=B(s,2);A(i);var g=B(i,2),_=e=>{var t=ko(),n=z(t,!0);V(()=>J(n,W(c).directory)),q(e,t)};Y(g,e=>{W(c).name&&W(c).directory&&e(_)});var v=B(g,2),y=z(v),b=B(v,2);let x;var S=L(b),ee=L(S);n(ee,()=>W(c),()=>!0,()=>!0),A(S);var C=B(S,2),w=e=>{var t=Fr();qr(R(t),()=>W(c).id,e=>{vo(e,{get session(){return W(c).id},get observedCommand(){return W(c).command},get suspended(){return W(l)},onProtectedChange:e=>{I(o,e,!0)}})}),q(e,t)},T=e=>{q(e,Lo())};Y(C,e=>{$.data?.control?e(w):e(T,-1)}),A(b);var te=B(b,2);Z(te,17,()=>W(a).filter(e=>e.session===W(c).id),e=>e.session,(e,t)=>{var n=Bo(),r=L(n),i=e=>{var n=Ro(),r=z(n,!0);V(()=>J(r,W(t).notice)),q(e,n)};Y(r,e=>{W(t).notice&&e(i)});var a=B(r,2),o=e=>{vo(e,{get session(){return W(c).id},review:`profile`})},s=e=>{var t=zo();V(e=>Q(t,`href`,e),[()=>`#/sessions/`+encodeURIComponent(W(c).id)+`?review=profile`]),q(e,t)};Y(a,e=>{W(t).pending&&W(l)?e(o):W(t).pending&&e(s,1)}),A(n),V(()=>Q(n,`id`,`quota-profile-`+W(c).id)),q(e,n)});var E=B(te,2),ne=e=>{var t=Fr();qr(R(t),()=>W(c).id,e=>{bo(e,{get session(){return W(c)},active:!0})}),q(e,t)};Y(E,e=>{W(l)||e(ne)}),A(t),V(e=>{f=ci(u,1,`lamp lit`,null,f,{working:W(c).status===`WORKING`&&!W(d)}),J(p,`${(W(d)?`STALE`:W(l)?`QUOTA THRESHOLD`:W(c).status)??``} // ${(W(c).name||W(c).directory)??``}`),J(y,`${e??``} TOKENS // ${W(c).id??``} // CONTEXT SOURCE // ${(W(c).source||`LOCAL`)??``}`),x=ui(b,``,x,{display:W(l)?`none`:void 0})},[()=>ia(W(c).tokens)]),G(`click`,m,()=>{h(r().id),ra(r().id,2)}),q(e,t)},u=e=>{q(e,Ho())};Y(i,e=>{W(c)?e(s):e(u,-1)}),q(e,t)},le=e=>{var t=es(),r=R(t),o=B(L(r),2),c=L(o),l=B(c,2);A(o),A(r);var f=B(r,2);Z(f,17,()=>W(i),e=>e.id,(e,t)=>{let r=N(()=>ta(W(t).id));var i=Qo();let o;var c=L(i),l=L(c),f=e=>{var n=Uo(),r=L(n),i=z(r);A(n),V(e=>{Q(r,`aria-pressed`,W(u)===W(t).id),J(i,`${e??``} // ${W(t).name??``}`)},[()=>Array.from(W(t).id).slice(-5).join(``).toUpperCase()]),G(`click`,r,()=>h(W(t).id)),q(e,n)};Y(l,e=>{W(t).name&&e(f)});var p=B(l,2),m=L(p),g=L(m);let _;var y=B(g,1,!0);A(m),A(p);var b=B(p,2),x=e=>{var n=Wo(),r=z(n,!0);V(()=>{Q(n,`aria-pressed`,W(u)===W(t).id),J(r,W(t).directory||W(t).id)}),G(`click`,n,()=>h(W(t).id)),q(e,n)};Y(b,e=>{W(t).name||e(x)});var S=B(b,2),ee=L(S);Te(),A(S);var C=B(S,2),w=e=>{var n=ko(),r=z(n,!0);V(()=>J(r,W(t).directory)),q(e,n)};Y(C,e=>{W(t).name&&W(t).directory&&e(w)});var T=B(C,2),te=z(T),E=B(T,2),ne=z(E),re=B(E,2),ie=L(re),D=B(ie,2),ae=z(D,!0),oe=B(D,2);A(re);var se=B(re,2),ce=B(se,2),le=e=>{var n=Go(),r=z(n,!0);V(()=>J(r,W(t).status===`CHECK SESSION`?`INFERRED INACTIVITY`:$.data?.control?`OPEN FULL DETAIL OR REPLY IN CODEX`:W(t).status===`APPROVAL NEEDED`?`APPROVE OR DECLINE IN CODEX`:`REPLY IN CODEX`)),q(e,n)},ue=N(()=>!W(d)&&[`APPROVAL NEEDED`,`INPUT NEEDED`,`CHECK SESSION`].includes(W(t).status));Y(ce,e=>{W(ue)&&e(le)}),A(c);var de=B(c,2),fe=e=>{var r=Xo(),i=L(r),o=L(i),c=z(o,!0),l=B(o,2),f=e=>{var n=Ko(),r=z(n,!0);V(e=>{Q(n,`href`,e),J(r,$.data?.control?`REVIEW IN FULL DETAIL ↗`:`APPROVAL IN CODEX ↗`)},[()=>`#/sessions/`+encodeURIComponent(W(t).id)]),q(e,n)};Y(l,e=>{!W(d)&&W(t).status===`APPROVAL NEEDED`&&e(f)}),A(i);var p=B(i,2);n(p,()=>W(t),()=>!1);var m=B(p,2);Z(m,17,()=>W(a).filter(e=>e.session===W(t).id),X,(e,n)=>{var r=Yo(),i=R(r),a=e=>{var t=qo(),r=z(t,!0);V(()=>J(r,W(n).notice)),q(e,t)};Y(i,e=>{W(n).notice&&e(a)});var o=B(i,2),c=e=>{var n=Jo();V(e=>Q(n,`href`,e),[()=>`#/sessions/`+encodeURIComponent(W(t).id)+`?review=profile`]),G(`click`,n,e=>s(e,W(t).id)),q(e,n)};Y(o,e=>{W(n).pending&&e(c)}),q(e,r)});var h=B(m,2);{let e=N(()=>W(u)===W(t).id);bo(h,{get session(){return W(t)},get active(){return W(e)}})}A(r),V(()=>J(c,W(t).contextKind||`LAST ACTIVITY`)),q(e,r)};Y(de,e=>{W(r)>0&&e(fe)});var pe=B(de,2),me=e=>{var n=Zo(),r=B(L(n),2);{let e=N(()=>(W(t).samples||[]).map(e=>e.tokens));qa(r,{get values(){return W(e)},capacity:120})}var i=z(B(r,2));A(n),V(()=>J(i,`LAST ${(W(t).samples?.length||0)??``} SAMPLES // AUTO SCALE`)),q(e,n)};Y(pe,e=>{W(r)<2&&e(me)}),A(i),V((e,n,a,s)=>{o=ci(i,1,`session-row`,null,o,{selected:W(u)===W(t).id,wide:W(r)===2,split:W(r)===1}),Q(i,`aria-label`,`Session `+(W(t).name||W(t).directory||W(t).id)),Q(m,`href`,e),_=ci(g,1,`lamp lit`,null,_,{working:W(t).status===`WORKING`&&!W(d)}),J(y,W(d)?`STALE`:W(t).status),J(ee,`${n??``} `),J(te,`${W(t).agents??``} LINKED AGENTS`),J(ne,`ACTIVE // ${a??``}`),ie.disabled=W(r)===0,Q(D,`aria-expanded`,W(r)>0),J(ae,W(r)?`HIDE DETAIL`:`SHOW DETAIL`),Q(se,`href`,s)},[()=>`#/sessions/`+encodeURIComponent(W(t).id),()=>ia(W(t).tokens),()=>aa(W(t).activity),()=>`#/sessions/`+encodeURIComponent(W(t).id)]),G(`click`,m,()=>h(W(t).id)),G(`click`,ie,()=>v(W(t).id,-1)),G(`click`,D,()=>{h(W(t).id),ra(W(t).id,+!W(r))}),G(`click`,oe,()=>v(W(t).id,1)),G(`click`,se,()=>h(W(t).id)),q(e,i)});var p=B(f,2),m=e=>{q(e,$o())};Y(p,e=>{W(i).length||e(m)}),G(`click`,c,()=>na(1)),G(`click`,l,()=>na(0)),q(e,t)};Y(se,e=>{r().id?e(ce):e(le,-1)}),V(e=>{J(ee,`OBSERVED ${e??``}`),w=ci(C,1,`session-totals`,null,w,{stale:W(d)}),J(te,`${W(d)?`LAST KNOWN TOTALS — live state counts unavailable. `:``}Tokens - observed since this server started for currently listed sessions; linked - agents are already included. Totals can decrease when a session leaves the - list. History samples every 30 seconds. Not account-wide totals.`)},[()=>aa($.data?.sessionsAt)]),q(e,x),qe()}wr([`click`]);var rs=K(`

      `),is=K(`

      Relative usage/credits are not tokens or quota percentages. Groupings - describe the same usage, not additional consumption.

      `,1),as=K(`// APPROXIMATE`),os=K(`

      Partial accounting: totals may still change.

      `),ss=K(`

      Percentage of this period's historical allowance, not today's limit. May - exceed 100%. This is not a manual/automatic reset log.

      `,1),cs=K(`

      `,1),ls=K(`

      This optional account report is unavailable. Token history remains - available; missing data does not mean zero usage.

      `),us=K(`
      `),ds=K(`
      `),fs=K(`

      No usage reported for this day.

      `),ps=K(`

      Selected breakdown unavailable; missing does not mean zero.

      `),ms=K(`

      `,1);function hs(e,t){Ke(t,!0);let n=F(`surface`),r=F(0),i=N(()=>t.reports?.daily),a=N(()=>t.reports?.plan),o=N(()=>t.mode===`breakdown`?W(i)?.days.length||0:W(a)?.periods.length||0),s=N(()=>Math.min(W(r),Math.max(0,W(o)-1))),c=N(()=>W(i)?.days[(W(i)?.days.length||0)-1-W(s)]),l=N(()=>W(a)?.periods[W(s)]),u=N(()=>t.mode===`breakdown`?W(i)?.units||``:`%`),d=N(()=>{let e=Object.create(null);if(t.mode===`breakdown`)e=W(c)?.groups[W(n)]||{};else{let t=W(n)===`feature`?`thread_source`:W(n)===`task start`?`turn_trigger`:W(n);for(let n of W(l)?.breakdowns||[])if(n.dimension===t)for(let t of n.rows)e[t.key]=(e[t.key]||0)+t.basis_points/100}return Object.entries(e).sort((e,t)=>t[1]-e[1]||e[0].localeCompare(t[0]))}),f=N(()=>t.mode===`breakdown`?W(c)?.total:W(l)?.used_basis_points==null?null:W(l).used_basis_points/100),p=N(()=>Math.max(1,W(f)||0,...W(d).map(([,e])=>e))),m=e=>e==null?`UNKNOWN`:e.toLocaleString(`en-GB`,{maximumFractionDigits:2});var h=ms(),g=R(h),v=L(g),y=B(L(v)),b=L(y);b.value=b.__value=`surface`;var x=B(b);x.value=x.__value=`model`;var S=B(x);S.value=S.__value=`feature`;var ee=B(S);ee.value=ee.__value=`task start`,A(y),mi(y),A(v);var C=B(v,2),w=B(C,2),T=z(B(w,2),!0);A(g);var te=B(g,2),E=L(te),ne=z(E,!0),re=B(E,2),ie=z(re),D=B(re,2),ae=e=>{var t=is(),n=R(t),r=z(n),a=B(n,2),o=e=>{var t=rs(),n=z(t);V(()=>J(n,`Data as of ${W(i).dataAsOf??``}`)),q(e,t)};Y(a,e=>{W(i).dataAsOf&&e(o)});var s=z(B(a,2));Te(2),V((e,t)=>{J(r,`Latest fetch ${e??``} // queried ${W(i).from??``} → ${W(i).through??``}. - Reports are fetched from OpenAI and are not saved between runs.`),J(s,`${(W(c)?.date||`No daily buckets reported`)??``} // ${t??``} - ${W(u)??``}`)},[()=>aa(W(i).fetchedAt),()=>m(W(f))]),q(e,t)},oe=e=>{var t=cs(),n=R(t),r=L(n);let i;var o=z(r,!0),s=B(r),c=e=>{q(e,as())};Y(s,e=>{W(a).approximate&&e(c)});var u=B(s);A(n);var d=B(n,2),p=L(d),h=B(p),g=e=>{var t=Pr();V(()=>J(t,`// Boundary tolerance ${W(a).boundary_tolerance_seconds??``}s`)),q(e,t)};Y(h,e=>{W(a).boundary_tolerance_seconds!=null&&e(g)}),A(d);var _=B(d,2),v=e=>{var t=ss(),n=R(t),r=L(n),i=B(r);let a;var o=z(i);A(n);var s=B(n,2),c=z(s),u=B(s,2),d=e=>{q(e,os())};Y(u,e=>{W(l).accounting_complete||e(d)}),Te(2),V(e=>{J(r,`${W(l).window_minutes??``} MIN // ${W(l).plan_type??``} // `),a=ci(i,1,`svelte-1fbui7h`,null,a,{muted:W(f)==null,"quota-near":W(f)!=null&&W(f)>=80&&W(f)<100,"quota-full":W(f)!=null&&W(f)>=100}),J(o,`USED ${e??``}${W(f)==null?``:`%`}`),J(c,`${W(l).starts_at??``} → ${W(l).ends_at??``}`)},[()=>m(W(f))]),q(e,t)};Y(_,e=>{W(l)&&e(v)}),V(e=>{i=ci(r,1,`svelte-1fbui7h`,null,i,{"quality-warning":!W(a).coverage_complete}),J(o,W(a).coverage_complete?`Reported range complete`:`Partial coverage`),J(u,` // Latest fetch ${e??``}`),J(p,`Coverage starts ${(W(a).coverage_start||`unknown`)??``} // Data as of ${(W(a).data_as_of||`unknown`)??``}`)},[()=>aa(W(a).fetchedAt)]),q(e,t)},se=e=>{q(e,ls())};Y(D,e=>{t.mode===`breakdown`&&W(i)?e(ae):t.mode===`periods`&&W(a)?e(oe,1):e(se,-1)});var ce=B(D,2),le=e=>{var n=ds(),r=L(n),i=z(B(L(r),2),!0);A(r),Z(B(r,2),17,()=>W(d),X,(e,n,r)=>{var i=N(()=>_(W(n),2));let a=()=>W(i)[0],o=()=>W(i)[1];var s=us();let c;var l=L(s),u=z(l,!0),d=B(l,2),f=L(d);let h;A(d);var g=z(B(d,2));A(s),V((e,n,i)=>{c=ci(s,1,`report-row svelte-1fbui7h`,null,c,{unknown:e,alternate:r%2==1}),Q(l,`title`,a()),J(u,a()),h=ui(f,``,h,{width:n}),J(g,`${i??``}${t.mode===`periods`?`%`:``}`)},[()=>a().trim().toLowerCase()===`unknown`,()=>`${Math.max(0,100*o()/W(p))}%`,()=>m(o())]),q(e,s)}),A(n),V(()=>J(i,t.mode===`breakdown`?W(u):`QUOTA USED`)),q(e,n)},ue=e=>{q(e,fs())},de=e=>{q(e,ps())};Y(ce,e=>{W(d).length?e(le):t.mode===`breakdown`&&W(c)?.total===0&&W(c)?.groups[W(n)]!=null?e(ue,1):(W(i)&&t.mode===`breakdown`||W(a)&&t.mode===`periods`)&&e(de,2)}),A(te),V(()=>{C.disabled=W(s)>=W(o)-1,w.disabled=W(s)===0,J(T,W(o)?`${W(s)+1} / ${W(o)}`:`NO DATA`),J(ne,t.mode===`breakdown`?`DAILY BREAKDOWN`:`QUOTA WINDOWS`),J(ie,`${(t.mode===`breakdown`?t.reports?.dailyStatus||`UNAVAILABLE`:t.reports?.planStatus||`UNAVAILABLE`)??``} // UTC`)}),hi(y,()=>W(n),e=>I(n,e)),G(`click`,C,()=>I(r,W(s)+1)),G(`click`,w,()=>I(r,W(s)-1)),q(e,h),qe()}wr([`click`]);var gs=864e5;function _s(e,t){let n=new Date(e);n.setUTCDate(1),n.setUTCMonth(n.getUTCMonth()+t);let r=new Date(Date.UTC(n.getUTCFullYear(),n.getUTCMonth()+1,0)).getUTCDate();return n.setUTCDate(Math.min(e.getUTCDate(),r)),n}function vs(e,t,n){let r=new Date(Date.UTC(e.getUTCFullYear(),e.getUTCMonth(),e.getUTCDate()));for(let e=0;;e++){let i=_s(r,-t),a=new Date(i.getTime()+gs);if(e===n)return{start:a,end:r};r=i}}var ys=K(``),bs=K(``,1),xs=K(`

      History refresh failed. Any displayed history is the last successful - observation.

      `),Ss=K(`

      Token history unavailable; other reports may still be available.

      `),Cs=K(``),ws=K(`
      `),Ts=K(`

      LESS ░ ▒ ▓ █ MORE // HOVER FOR DATE AND TOKENS

      `,1),Es=K(`
      `,1),Ds=K(` `),Os=K(`

      LIFETIME TOKENS

      PEAK DAY

      CURRENT STREAK

      DAYS

      LONGEST TURN

      SECONDS

      LONGEST STREAK

      DAYS

      OPENAI // UTC

      Accessible data table
      Date (UTC)Tokens

      `,1),ks=K(`

      History unavailable or awaiting a matching account observation. Missing - history is not treated as zero usage.

      `),As=K(`

      USAGE // ACCOUNT HISTORY

      Account-wide history reported by Codex, not the local Sessions counter. Dates - use UTC. Allowance history is approximate where indicated; reset causes are - not provided.

      `,1);function js(e,t){Ke(t,!0);let n=F(`daily`),r=F(12),i=F(0),a=N(()=>{let e=$.data?.usage?.dailyUsageBuckets;if(!e)return[];let{start:t,end:n}=vs(new Date,W(r),W(i)),a=new Map;for(let t of e)!Number.isFinite(t.tokens)||t.tokens<0||a.set(t.startDate,(a.get(t.startDate)||0)+t.tokens);let o=[];for(let e=t;e<=n;e=new Date(e.getTime()+864e5)){let t=e.toISOString().slice(0,10);o.push({date:t,tokens:a.get(t)||0})}return o}),o=N(()=>Math.max(1,...W(a).map(e=>e.tokens))),s=N(()=>W(a).length?new Date(W(a)[0].date+`T00:00:00Z`).getUTCDay():0),c=N(()=>{if(W(n)===`monthly`){let e=new Map;for(let t of W(a))e.set(t.date.slice(0,7),(e.get(t.date.slice(0,7))||0)+t.tokens);return[...e].map(([e,t])=>({date:e,tokens:t}))}let e=0;return W(a).map(t=>({date:t.date,tokens:e+=t.tokens}))});var l=As(),u=B(R(l),4);Z(u,20,()=>[[`daily`,`Daily`],[`monthly`,`Monthly`],[`cumulative`,`Cumulative`],[`breakdown`,`Breakdown`],[`periods`,`Windows`]],X,(e,t)=>{var r=N(()=>_(t,2));let a=()=>W(r)[0],o=()=>W(r)[1];var s=ys();let c;var l=z(s,!0);V(()=>{Q(s,`aria-pressed`,W(n)===a()),c=ci(s,1,``,null,c,{active:W(n)===a()}),J(l,o())}),G(`click`,s,()=>{I(n,a(),!0),I(i,0)}),q(e,s)}),A(u);var d=B(u,2),f=L(d),p=e=>{var t=bs(),n=R(t),a=B(L(n)),o=L(a);o.value=o.__value=6;var s=B(o);s.value=s.__value=12,A(a),mi(a),A(n);var c=B(n),l=B(c);V(()=>l.disabled=W(i)===0),G(`change`,a,()=>I(i,0)),hi(a,()=>W(r),e=>I(r,e)),G(`click`,c,()=>Jt(i)),G(`click`,l,()=>Jt(i,-1)),q(e,t)};Y(f,e=>{W(n)!==`breakdown`&&W(n)!==`periods`&&e(p)}),A(d);var m=B(d,2),h=e=>{q(e,xs())};Y(m,e=>{$.data?.usageError&&e(h)});var g=B(m,2),v=e=>{q(e,Ss())};Y(g,e=>{$.data?.usage?.tokenStatus===`UNAVAILABLE`&&W(n)!==`breakdown`&&W(n)!==`periods`&&e(v)});var y=B(g,2),b=e=>{var t=Fr();qr(R(t),()=>W(n),e=>{{let t=N(()=>$.data?.usage?.reports);hs(e,{get reports(){return W(t)},get mode(){return W(n)}})}}),q(e,t)},x=e=>{var t=Os(),r=R(t),i=L(r),l=z(B(L(i),2),!0);A(i);var u=B(i,2),d=z(B(L(u),2),!0);A(u);var f=B(u,2),p=B(L(f),2),m=L(p);Te(),A(p),A(f);var h=B(f,2),g=B(L(h),2),_=L(g);Te(),A(g),A(h);var v=B(h,2),y=B(L(v),2),b=L(y);Te(),A(y),A(v),A(r);var x=B(r,4),S=L(x),ee=z(S),C=B(S,2),w=e=>{var t=Ts(),n=R(t),r=L(n),i=L(r);Z(i,17,()=>Array(W(s)),X,(e,t)=>{q(e,Cs())}),Z(B(i,2),17,()=>W(a),X,(e,t)=>{var n=ws();let r,i;V(e=>{r=ci(n,1,`heat-cell`,null,r,{zero:W(t).tokens===0}),Q(n,`title`,e),i=ui(n,``,i,{opacity:W(t).tokens?.25+.75*W(t).tokens/W(o):1})},[()=>`${W(t).date}: ${ia(W(t).tokens)} tokens`]),q(e,n)}),A(r),A(n),Te(2),q(e,t)},T=e=>{var t=Es(),r=R(t);{let e=N(()=>W(c).map(e=>e.tokens)),t=N(()=>W(n)+` usage`);qa(r,{get values(){return W(e)},get label(){return W(t)}})}var i=B(r,2),a=L(i),o=z(a,!0),s=z(B(a),!0);A(i),V(e=>{J(o,W(c)[0]?.date),J(s,e)},[()=>W(c).at(-1)?.date]),q(e,t)};Y(C,e=>{W(n)===`daily`?e(w):e(T,-1)});var te=B(C,2),E=B(L(te),2),ne=L(E),re=B(L(ne));Z(re,21,()=>W(n)===`daily`?W(a):W(c),X,(e,t)=>{var n=Ds(),r=L(n),i=z(r,!0),a=z(B(r),!0);A(n),V(e=>{J(i,W(t).date),J(a,e)},[()=>ia(W(t).tokens)]),q(e,n)}),A(re),A(ne),A(E),A(te),A(x);var ie=z(B(x,2));V((e,t,n,r,i,o,s)=>{J(l,e),J(d,t),J(m,`${n??``} `),J(_,`${r??``} `),J(b,`${i??``} `),J(ee,`${W(a)[0]?.date??``} — ${o??``}`),J(ie,`OBSERVED ${s??``} // CUMULATIVE IS FOR THE SELECTED PERIOD`)},[()=>ia($.data.usage.summary.lifetimeTokens),()=>ia($.data.usage.summary.peakDailyTokens),()=>ia($.data.usage.summary.currentStreakDays),()=>ia($.data.usage.summary.longestRunningTurnSec),()=>ia($.data.usage.summary.longestStreakDays),()=>W(a).at(-1)?.date,()=>aa($.data.usageAt)]),q(e,t)},S=e=>{q(e,ks())};Y(y,e=>{W(n)===`breakdown`||W(n)===`periods`?e(b):$.data?.usage&&$.data.usage.dailyUsageBuckets!==null?e(x,1):e(S,-1)}),q(e,l),qe()}wr([`click`,`change`]);var Ms=K(`

      Page not found

      Return to Quota

      `,1);function Ns(e){var t=Ms();Te(2),q(e,t)}var Ps=K(` `),Fs=K(`

      `),Is=K(`

      Connecting to your local Codexometer…

      `),Ls=K(``),Rs=K(`
      CODEXOMETER

      Your quota. Your sessions. Your command centre.

      `);function zs(e,t){Ke(t,!0);let n={"/":Wa,"/quota/:view?":Wa,"/sessions/:id?":ns,"/usage":js,"*":Ns},r={quota:/^(?:\/|\/quota(?:\/[^/]+)?\/?)$/,sessions:/^\/sessions(?:\/[^/]+)?\/?$/,usage:/^\/usage\/?$/},i=F(`hacker`),a=[`hacker`,`rust`,`blue-steel`,`ultraviolet`,`nightshade`];bn(()=>{$i()}),bn(()=>{if($.data)for(let[e,t]of Object.entries(r))t.test(Wi.location)&&(Qi.tab=e)}),Vi(()=>{try{let e=localStorage.getItem(`codexometer.web.theme`);e&&a.includes(e)&&I(i,e,!0)}catch{}return ua()});function o(){try{localStorage.setItem(`codexometer.web.theme`,W(i))}catch{}}var s=Rs(),c=L(s),l=B(L(c),2),u=L(l);let d;var f=B(u,1,!0),p=z(B(f));A(l),A(c);var m=B(c,2);Z(m,21,()=>Object.entries(r),X,(e,t)=>{var n=N(()=>_(W(t),2));let r=()=>W(n)[0],i=()=>W(n)[1],a=N(()=>i().test(Wi.location));var o=Ps();let s;var c=z(o,!0);V(e=>{Q(o,`href`,r()===`quota`?`#/quota/`+Qi.view:`#/`+r()),Q(o,`aria-current`,W(a)?`page`:void 0),s=ci(o,1,``,null,s,{active:W(a)}),J(c,e)},[()=>r().toUpperCase()]),q(e,o)}),A(m);var h=B(m,2),g=L(h),v=e=>{var t=Fs(),n=z(t,!0);V(()=>J(n,$.error)),q(e,t)};Y(g,e=>{$.error&&e(v)});var y=B(g,2),b=e=>{qi(e,{get routes(){return n}})},x=e=>{q(e,Is())};Y(y,e=>{$.data?e(b):$.error||e(x,1)}),A(h);var S=B(h,2),ee=L(S),C=z(ee),w=B(ee,2),T=z(w,!0),te=B(w,2),E=B(L(te));Z(E,21,()=>a,X,(e,t)=>{var n=Ls(),r=z(n,!0),i={};V(e=>{J(r,e),i!==(i=W(t))&&(n.value=(n.__value=i)??``)},[()=>W(t).replace(`-`,` `).toUpperCase()]),q(e,n)}),A(E),mi(E),A(te),A(S),A(s),V(()=>{Q(s,`data-theme`,W(i)),d=ci(u,1,`lamp`,null,d,{lit:$.connected}),J(f,$.connected?`CONNECTED`:`OFFLINE`),J(p,`EXPERIMENTAL // ${$.data?.control?`SESSION CONTROL`:`READ ONLY`}`),J(C,`v${($.data?.version||`…`)??``} // LOCAL ONLY`),J(T,$.data?.control?`Session control enabled. Stop the server to revoke access.`:`No actions can be sent from this preview.`)}),G(`change`,E,o),hi(E,()=>W(i),e=>I(i,e)),q(e,s),qe()}wr([`change`]),Vr(zs,{target:document.getElementById(`app`)}); \ No newline at end of file diff --git a/internal/web/dist/assets/index-CLM8akQt.js b/internal/web/dist/assets/index-CLM8akQt.js new file mode 100644 index 0000000..78b9e7d --- /dev/null +++ b/internal/web/dist/assets/index-CLM8akQt.js @@ -0,0 +1,42 @@ +(function(){let e=document.createElement(`link`).relList;if(e&&e.supports&&e.supports(`modulepreload`))return;for(let e of document.querySelectorAll(`link[rel="modulepreload"]`))n(e);new MutationObserver(e=>{for(let t of e)if(t.type===`childList`)for(let e of t.addedNodes)e.tagName===`LINK`&&e.rel===`modulepreload`&&n(e)}).observe(document,{childList:!0,subtree:!0});function t(e){let t={};return e.integrity&&(t.integrity=e.integrity),e.referrerPolicy&&(t.referrerPolicy=e.referrerPolicy),t.credentials=e.crossOrigin===`use-credentials`?`include`:e.crossOrigin===`anonymous`?`omit`:`same-origin`,t}function n(e){if(e.ep)return;e.ep=!0;let n=t(e);fetch(e.href,n)}})();var e=Array.isArray,t=Array.prototype.indexOf,n=Array.prototype.includes,r=Array.from,i=Object.defineProperty,a=Object.getOwnPropertyDescriptor,o=Object.getOwnPropertyDescriptors,s=Object.prototype,c=Array.prototype,l=Object.getPrototypeOf,u=Object.isExtensible;function d(e){return typeof e==`function`}var f=()=>{};function p(e){return e()}function m(e){for(var t=0;t{e=n,t=r}),resolve:e,reject:t}}function g(e,t,n=!1){return e===void 0?n?t():t:e}function _(e,t){if(Array.isArray(e))return e;if(t===void 0||!(Symbol.iterator in e))return Array.from(e);let n=[];for(let r of e)if(n.push(r),n.length===t)break;return n}var v=1024,y=2048,b=4096,x=8192,S=16384,C=32768,w=1<<25,T=65536,E=1<<19,ee=1<<20,D=1<<25,te=1<<21,ne=1<<22,re=1<<23,O=Symbol(`$state`),ie=Symbol(`component`),ae=Symbol(`legacy props`),oe=Symbol(``),se=Symbol(`attributes`),ce=Symbol(`class`),le=Symbol(`style`),ue=Symbol(`text`),de=Symbol(`form reset`),fe=new class extends Error{name=`StaleReactionError`;message="The reaction that called `getAbortSignal()` was re-run or destroyed"},pe=!!globalThis.document?.contentType&&globalThis.document.contentType.includes(`xml`),me={},k=Symbol(`uninitialized`),he=`http://www.w3.org/1999/xhtml`;function ge(){console.warn(`https://svelte.dev/e/derived_inert`)}function _e(e){console.warn(`https://svelte.dev/e/hydration_mismatch`)}function ve(){console.warn(`https://svelte.dev/e/select_multiple_invalid_value`)}function ye(){console.warn(`https://svelte.dev/e/svelte_boundary_reset_noop`)}var A=!1;function be(e){A=e}var j;function xe(e){if(e===null)throw _e(),me;return j=e}function Se(){return xe(sn(j))}function M(e){if(A){if(sn(j)!==null)throw _e(),me;j=e}}function Ce(e=1){if(A){for(var t=e,n=j;t--;)n=sn(n);j=n}}function we(e=!0){for(var t=0,n=j;;){if(n.nodeType===8){var r=n.data;if(r===`]`){if(t===0)return n;--t}else(r===`[`||r===`[!`||r[0]===`[`&&!isNaN(Number(r.slice(1))))&&(t+=1)}var i=sn(n);e&&n.remove(),n=i}}function Te(e){if(!e||e.nodeType!==8)throw _e(),me;return e.data}function Ee(e){return e===this.v}function De(e,t){return e==e?e!==t||typeof e==`object`&&!!e||typeof e==`function`:t==t}function Oe(e){return!De(e,this.v)}function ke(e){throw Error(`https://svelte.dev/e/lifecycle_outside_component`)}function Ae(){throw Error(`https://svelte.dev/e/async_derived_orphan`)}function je(e,t,n){throw Error(`https://svelte.dev/e/each_key_duplicate`)}function Me(e){throw Error(`https://svelte.dev/e/effect_in_teardown`)}function Ne(){throw Error(`https://svelte.dev/e/effect_in_unowned_derived`)}function Pe(e){throw Error(`https://svelte.dev/e/effect_orphan`)}function Fe(){throw Error(`https://svelte.dev/e/effect_update_depth_exceeded`)}function Ie(e){throw Error(`https://svelte.dev/e/props_invalid_value`)}function Le(){throw Error(`https://svelte.dev/e/state_descriptors_fixed`)}function Re(){throw Error(`https://svelte.dev/e/state_prototype_fixed`)}function ze(){throw Error(`https://svelte.dev/e/state_unsafe_mutation`)}function Be(){throw Error(`https://svelte.dev/e/svelte_boundary_reset_onerror`)}var Ve=!1;function He(){Ve=!0}var N=null;function Ue(e){N=e}function We(e,t=!1,n){N={p:N,i:!1,c:null,e:null,s:e,x:null,r:W,l:Ve&&!t?{s:null,u:null,$:[]}:null}}function Ge(e){var t=N,n=t.e;if(n!==null){t.e=null;for(var r of n)bn(r)}return e!==void 0&&(t.x=e),t.i=!0,N=t.p,Ke(e)}function Ke(e={}){return i(e,ie,{value:!0}),e}function qe(){return!Ve||N!==null&&N.l===null}var Je=[];function Ye(){var e=Je;Je=[],m(e)}function Xe(e){if(Je.length===0&&!wt){var t=Je;queueMicrotask(()=>{t===Je&&Ye()})}Je.push(e)}function Ze(){for(;Je.length>0;)Ye()}var Qe=~(y|b|v);function $e(e,t){e.f=e.f&Qe|t}function et(e){e.f&512||e.deps===null?$e(e,v):$e(e,b)}function tt(e,t,n){e.f&2048?t.add(e):e.f&4096&&n.add(e),$e(e,v)}function nt(e){A&&on(e)!==null&&cn(e)}var rt=!1;function it(){rt||(rt=!0,document.addEventListener(`reset`,e=>{Promise.resolve().then(()=>{if(!e.defaultPrevented)for(let t of e.target.elements)t[de]?.()})},{capture:!0}))}function at(e){var t=U,n=W;Wn(null),Gn(null);try{return e()}finally{Wn(t),Gn(n)}}function ot(e,t,n,r=n){e.addEventListener(t,()=>at(n));let i=e[de];e[de]=i?()=>{i(),r(!0)}:()=>r(!0),it()}function st(e,t,n,r){let i=qe()?dt:mt;var a=e.filter(e=>!e.settled),o=t.map(i);if(n.length===0&&a.length===0){r(o);return}var s=W,c=ct(),l=a.length===1?a[0].promise:a.length>1?Promise.all(a.map(e=>e.promise)):null;function u(e){if(!(s.f&16384)){c();try{r([...o,...e])}catch(e){pn(e,s)}lt()}}var d=ut();if(n.length===0){l.then(()=>u([])).finally(d);return}function f(){Promise.all(n.map(e=>pt(e))).then(u).catch(e=>pn(e,s)).finally(d)}l?l.then(()=>{c(),f(),lt()}):f()}function ct(){var e=W,t=U,n=N,r=F;return function(i=!0){Gn(e),Wn(t),Ue(n),i&&!(e.f&16384)&&(r?.activate(),r?.apply())}}function lt(e=!0){Gn(null),Wn(null),Ue(null),e&&F?.deactivate()}function ut(){var e=W,t=e.b,n=F,r=!!t?.is_rendered();return t?.update_pending_count(1,n),n.increment(r,e),()=>{t?.update_pending_count(-1,n),n.decrement(r,e)}}function dt(e){var t=2|y;return W!==null&&(W.f|=E),{ctx:N,deps:null,effects:null,equals:Ee,f:t,fn:e,reactions:null,rv:0,v:k,wv:0,parent:W,ac:null}}var ft=Symbol(`obsolete`);function pt(e,t,n){let r=W;r===null&&Ae();var i=void 0,a=Vt(k),o=!U,s=new Set;return wn(()=>{var t=W,n=h();i=n.promise;try{Promise.resolve(e()).then(n.resolve,e=>{e!==fe&&n.reject(e)}).finally(lt)}catch(e){n.reject(e),lt()}var c=F;if(o){if(t.f&32768)var l=ut();if(r.b?.is_rendered())c.async_deriveds.get(t)?.reject(ft);else for(let e of s.values())e.reject(ft);s.add(n),c.async_deriveds.set(t,n)}let u=(e,t=void 0)=>{l?.(),s.delete(n),t!==ft&&(c.activate(),t?(a.f|=re,Gt(a,t)):(a.f&8388608&&(a.f^=re),Gt(a,e)),c.deactivate())};n.promise.then(u,e=>u(null,e||`unknown`))}),vn(()=>{for(let e of s)e.reject(ft)}),new Promise(e=>{function t(n){function r(){n===i?e(a):t(i)}n.then(r,r)}t(i)})}function P(e){let t=dt(e);return qn(t),t}function mt(e){let t=dt(e);return t.equals=Oe,t}function ht(e){var t=e.effects;if(t!==null){e.effects=null;for(var n=0;n{t.ac.abort(fe),t.ac=null}),t.fn!==null&&(t.teardown=f),cr(t,0),kn(t))}function yt(e){if(e.effects!==null)for(let t of e.effects)t.teardown&&t.fn!==null&&lr(t)}var bt=null,F=null,xt=null,St=null,Ct=null,wt=!1,Tt=!1,Et=null,Dt=null,Ot=0,kt=1,At=class e{id=kt++;#e=!1;linked=!0;#t=null;#n=null;async_deriveds=new Map;current=new Map;previous=new Map;#r=new Set;#i=new Set;#a=0;#o=new Map;#s=null;#c=[];#l=[];#u=new Set;#d=new Set;#f=new Map;#p=new Set;is_fork=!1;#m=!1;constructor(){bt===null?bt=this:(bt.#n=this,this.#t=bt),bt=this}#h(){if(this.is_fork)return!0;for(let n of this.#o.keys()){for(var e=n,t=!1;e.parent!==null;){if(this.#f.has(e)){t=!0;break}e=e.parent}if(!t)return!0}return!1}skip_effect(e){this.#f.has(e)||this.#f.set(e,{d:[],m:[]}),this.#p.delete(e)}unskip_effect(e,t=e=>this.schedule(e)){var n=this.#f.get(e);if(n){this.#f.delete(e);for(var r of n.d)$e(r,y),t(r);for(r of n.m)$e(r,b),t(r)}this.#p.add(e)}#g(){var e=[];for(let i of this.#c)if(!(i.f&16384||!(i.f&6144))){for(var t=i,n=!1;t.parent!==null;){t=t.parent;var r=t.f;if(r&96){if(!(r&1024)){n=!0;break}t.f^=v}}n||e.push(t)}return this.#c=[],e}#_(){this.#e=!0;for(let e of this.#u)this.#d.delete(e),$e(e,y),this.schedule(e);for(let e of this.#d)$e(e,b),this.schedule(e);this.apply();for(var t=Et=[],n=[],r=Dt=[];this.#c.length>0;){Ot++>1e3&&(this.#S(),Mt());for(let e of this.#g())try{this.#v(e,t,n)}catch(t){throw Lt(e),this.#h()||this.discard(),t}}if(F=null,r.length>0){var i=e.ensure();for(let e of r)i.schedule(e)}if(Et=null,Dt=null,this.#h()){this.#x(n),this.#x(t);for(let[e,t]of this.#f)It(e,t);r.length>0&&F.#_();return}let a=this.#y();if(a){this.#x(n),this.#x(t),a.#b(this);return}this.#u.clear(),this.#d.clear();for(let e of this.#r)e(this);this.#r.clear(),xt=this,Pt(n),Pt(t),xt=null,this.#s?.resolve();var o=F;if(this.#a===0&&(this.#c.length===0||o!==null)&&this.#S(),this.#c.length>0){if(o!==null){for(let e of this.#c)o.#c.push(e);this.#c=[]}else o=this}o!==null&&(zt.clear(),o.#_())}#v(e,t,n){e.f^=v;for(var r=e.first;r!==null;){var i=r.f,a=!!(i&96);if(!(a&&i&1024||i&8192||this.#f.has(r))&&r.fn!==null){a?r.f^=v:i&4?t.push(r):rr(r)&&(i&16&&this.#d.add(r),lr(r));var o=r.first;if(o!==null){r=o;continue}}for(;r!==null;){var s=r.next;if(s!==null){r=s;break}r=r.parent}}}#y(){for(var e=this.#t;e!==null;){if(!e.is_fork){for(let[t,[,n]]of this.current)if(e.current.has(t)&&!n)return e}e=e.#t}return null}#b(e){for(let[t,n]of e.current)!this.previous.has(t)&&e.previous.has(t)&&this.previous.set(t,e.previous.get(t)),this.current.set(t,n);for(let[t,n]of e.async_deriveds){let e=this.async_deriveds.get(t);e&&n.promise.then(e.resolve).catch(e.reject)}e.async_deriveds.clear(),this.transfer_effects(e.#u,e.#d);let t=e=>{var n=e.reactions;if(n!==null&&!(e.f&2&&!(e.f&6144)))for(let e of n){var r=e.f;if(r&2)t(e);else{var i=e;r&4194320&&!this.async_deriveds.has(i)&&(this.#d.delete(i),$e(i,y),this.schedule(i))}}};for(let e of this.current.keys())t(e);this.oncommit(()=>e.discard()),e.#S(),F=this,this.#_()}#x(e){for(var t=0;t{this.#m=!1,this.linked&&this.flush()}))}transfer_effects(e,t){for(let t of e)this.#u.add(t);for(let e of t)this.#d.add(e);e.clear(),t.clear()}oncommit(e){this.#r.add(e)}ondiscard(e){this.#i.add(e)}settled(){return(this.#s??=h()).promise}static ensure(){if(F===null){let t=F=new e;!Tt&&!wt&&Xe(()=>{t.#e||t.flush()})}return F}apply(){St=null}schedule(e){if(Ct=e,e.b?.is_pending&&e.f&16777228&&!(e.f&32768)){e.b.defer_effect(e);return}this.#c.push(e)}#S(){if(this.linked){var e=this.#t,t=this.#n;e===null||(e.#n=t),t===null?bt=e:t.#t=e,this.linked=!1}}};function jt(e){var t=wt;wt=!0;try{var n;for(e&&(F!==null&&!F.is_fork&&F.flush(),n=e());;){if(Ze(),F===null)return n;F.flush()}}finally{wt=t}}function Mt(){try{Fe()}catch(e){pn(e,Ct)}}var Nt=null;function Pt(e){var t=e.length;if(t!==0){for(var n=0;n0)){zt.clear();for(let e of Nt){if(e.f&24576)continue;let t=[e],n=e.parent;for(;n!==null;)Nt.has(n)&&(Nt.delete(n),t.push(n)),n=n.parent;for(let e=t.length-1;e>=0;e--){let n=t[e];n.f&24576||lr(n)}}Nt.clear()}}Nt=null}}function Ft(e){F.schedule(e)}function It(e,t){if(!(e.f&32&&e.f&1024)){e.f&2048?t.d.push(e):e.f&4096&&t.m.push(e),$e(e,v);for(var n=e.first;n!==null;)It(n,t),n=n.next}}function Lt(e){$e(e,v);for(var t=e.first;t!==null;)Lt(t),t=t.next}var Rt=new Set,zt=new Map,Bt=!1;function Vt(e,t){return{f:0,v:e,reactions:null,equals:Ee,rv:0,wv:0}}function I(e,t){let n=Vt(e,t);return qn(n),n}function Ht(e,t=!1,n=!0){let r=Vt(e);return t||(r.equals=Oe),Ve&&n&&N!==null&&N.l!==null&&(N.l.s??=[]).push(r),r}function L(e,t,n=!1){return U!==null&&(!Un||U.f&131072)&&qe()&&U.f&4325394&&(Kn===null||!Kn.has(e))&&ze(),Gt(e,n?Xt(t):t,Dt)}var Ut=null,Wt=0;function Gt(e,t,n=null){if(!e.equals(t)){Vn?zt.set(e,t):zt.has(e)||zt.set(e,e.v);var r=At.ensure();if(r.capture(e,t),e.f&2){let t=e;e.f&2048&>(t),St===null&&et(t)}e.wv=nr(),Ut=null,Wt=0,Yt(e,y,n),Ut=null,qe()&&W!==null&&W.f&1024&&!(W.f&96)&&(Xn===null?Zn([e]):Xn.push(e)),!r.is_fork&&Rt.size>0&&!Bt&&Kt()}return t}function Kt(){Bt=!1;for(let e of Rt){e.f&1024&&$e(e,b);let t;try{t=rr(e)}catch{t=!0}t&&lr(e)}Rt.clear()}function qt(e,t=1){var n=G(e),r=t===1?n++:n--;return L(e,n),r}function Jt(e){L(e,e.v+1)}function Yt(e,t,n){var r=e.reactions;if(r!==null){var i=qe(),a=r.length;if(Wt+=a,Wt>1e5&&Ut===null&&(Ut=new Set),Ut!==null){if(Ut.has(e))return;Ut.add(e)}for(var o=0;o{if(er===d)return e();var t=U,n=er;Wn(null),tr(d);var r=e();return Wn(t),tr(n),r};return i&&r.set(`length`,I(t.length,u)),new Proxy(t,{defineProperty(e,t,n){(!(`value`in n)||n.configurable===!1||n.enumerable===!1||n.writable===!1)&&Le();var i=r.get(t);return i===void 0?f(()=>{var e=I(n.value,u);return r.set(t,e),e}):L(i,n.value,!0),!0},deleteProperty(e,t){var n=r.get(t);if(n===void 0){if(t in e){let e=f(()=>I(k,u));r.set(t,e),Jt(o)}}else L(n,k),Jt(o);return!0},get(e,n,i){if(n===O)return t;var o=r.get(n),s=n in e;if(o===void 0&&(!s||a(e,n)?.writable)&&(o=f(()=>I(Xt(s?e[n]:k),u)),r.set(n,o)),o!==void 0){var c=G(o);return c===k?void 0:c}return Reflect.get(e,n,i)},getOwnPropertyDescriptor(e,t){this.has?.(e,t);var n=Reflect.getOwnPropertyDescriptor(e,t),i=r.get(t);if(i!==void 0){var a=G(i);if(a===k)return;if(n&&`value`in n)n.value=a;else return{enumerable:!0,configurable:!0,value:a,writable:!0}}return n},has(e,t){if(t===O)return!0;var n=r.get(t),i=n!==void 0&&n.v!==k||Reflect.has(e,t);return(n!==void 0||W!==null&&(!i||a(e,t)?.writable))&&(n===void 0&&(n=f(()=>I(i?Xt(e[t]):k,u)),r.set(t,n)),G(n)===k)?!1:i},set(e,t,n,s){var c=r.get(t),l=t in e;if(i&&t===`length`)for(var d=n;dI(k,u)),r.set(d+``,p)):L(p,k)}if(c===void 0)(!l||a(e,t)?.writable)&&(c=f(()=>I(void 0,u)),L(c,Xt(n)),r.set(t,c));else{l=c.v!==k;var m=f(()=>Xt(n));L(c,m)}var h=Reflect.getOwnPropertyDescriptor(e,t);if(h?.set&&h.set.call(s,n),!l){if(i&&typeof t==`string`){var g=r.get(`length`),_=Number(t);Number.isInteger(_)&&_>=g.v&&L(g,_+1)}Jt(o)}return!0},ownKeys(e){G(o);var t=Reflect.ownKeys(e).filter(e=>{var t=r.get(e);return t===void 0||t.v!==k});for(var[n,i]of r)i.v!==k&&!(n in e)&&t.push(n);return t},setPrototypeOf(){Re()}})}function Zt(e){try{if(typeof e==`object`&&e&&O in e)return e[O]}catch{}return e}function Qt(e,t){return Object.is(Zt(e),Zt(t))}var $t,en,tn,nn;function rn(){if($t===void 0){$t=window,en=/Firefox/.test(navigator.userAgent);var e=Element.prototype,t=Node.prototype,n=Text.prototype;tn=a(t,`firstChild`).get,nn=a(t,`nextSibling`).get,u(e)&&(e[ce]=void 0,e[se]=null,e[le]=void 0,e.__e=void 0),u(n)&&(n[ue]=void 0)}}function an(e=``){return document.createTextNode(e)}function on(e){return tn.call(e)}function sn(e){return nn.call(e)}function R(e,t){if(!A)return on(e);var n=on(j);if(n===null)n=j.appendChild(an());else if(t&&n.nodeType!==3){var r=an();return n?.before(r),xe(r),r}return t&&dn(n),xe(n),n}function z(e,t=!1){if(!A){var n=on(e);return n instanceof Comment&&n.data===``?sn(n):n}if(t){if(j?.nodeType!==3){var r=an();return j?.before(r),xe(r),r}dn(j)}return j}function B(e,t=!1){if(!A)return on(e);var n=R(e,t);return M(e),n}function V(e,t=1,n=!1){let r=A?j:e;for(var i;t--;)i=r,r=sn(r);if(!A)return r;if(n){if(r?.nodeType!==3){var a=an();return r===null?i?.after(a):r.before(a),xe(a),a}dn(r)}return xe(r),r}function cn(e){e.textContent=``}function ln(){return!1}function un(e,t,n){return t==null||t===`http://www.w3.org/1999/xhtml`?n?document.createElement(e,{is:n}):document.createElement(e):n?document.createElementNS(t,e,{is:n}):document.createElementNS(t,e)}function dn(e){if(e.nodeValue.length<65536)return;let t=e.nextSibling;for(;t!==null&&t.nodeType===3;)t.remove(),e.nodeValue+=t.nodeValue,t=e.nextSibling}function fn(e){var t=W;if(t===null)return U.f|=re,e;if(!(t.f&32768)&&!(t.f&4))throw e;pn(e,t)}function pn(e,t){if(!(t!==null&&t.f&16384)){for(;t!==null;){if(t.f&128&&!(t.f&33570816)){if(!(t.f&32768))throw e;try{t.b.error(e);return}catch(t){e=t}}t=t.parent}throw e}}function mn(e){W===null&&(U===null&&Pe(e),Ne()),Vn&&Me(e)}function hn(e,t){var n=t.last;n===null?t.last=t.first=e:(n.next=e,e.prev=n,t.last=e)}function gn(e,t){var n=W;n!==null&&n.f&8192&&(e|=x);var r={ctx:N,deps:null,nodes:null,f:e|y|512,first:null,fn:t,last:null,next:null,parent:n,b:n&&n.b,prev:null,teardown:null,wv:0,ac:null};F?.register_created_effect(r);var i=r;if(e&4)Et===null?At.ensure().schedule(r):Et.push(r);else if(t!==null){try{lr(r)}catch(e){throw jn(r),e}i.deps===null&&i.teardown===null&&i.nodes===null&&i.first===i.last&&!(i.f&524288)&&(i=i.first,e&16&&e&65536&&i!==null&&(i.f|=T))}if(i!==null&&(i.parent=n,n!==null&&hn(i,n),U!==null&&U.f&2&&!(e&64))){var a=U;(a.effects??=[]).push(i)}return r}function _n(){return U!==null&&!Un}function vn(e){let t=gn(8,null);return $e(t,v),t.teardown=e,t}function yn(e){mn(`$effect`);var t=W.f;if(!U&&t&32&&N!==null&&!N.i){var n=N;(n.e??=[]).push(e)}else return bn(e)}function bn(e){return gn(4|ee,e)}function xn(e){return mn(`$effect.pre`),gn(8|ee,e)}function Sn(e){At.ensure();let t=gn(64|E,e);return(e={})=>new Promise(n=>{e.outro?Pn(t,()=>{jn(t),n(void 0)}):(jn(t),n(void 0))})}function Cn(e){return gn(4,e)}function wn(e){return gn(ne|E,e)}function Tn(e,t=0){return gn(8|t,e)}function H(e,t=[],n=[],r=[]){st(r,t,n,t=>{gn(8,()=>{e(...t.map(G))})})}function En(e,t=0){return gn(16|t,e)}function Dn(e){return gn(32|E,e)}function On(e){var t=e.teardown;if(t!==null){let n=Vn,r=U;Hn(!0),Wn(null);try{t.call(null)}catch(t){pn(t,e.parent)}finally{Hn(n),Wn(r)}}}function kn(e,t=!1){var n=e.first;for(e.first=e.last=null;n!==null;){let e=n.ac;e!==null&&at(()=>{e.abort(fe)});var r=n.next;n.f&64?n.parent=null:jn(n,t),n=r}}function An(e){for(var t=e.first;t!==null;){var n=t.next;t.f&32||jn(t),t=n}}function jn(e,t=!0){var n=!1;(t||e.f&262144)&&e.nodes!==null&&e.nodes.end!==null&&(Mn(e.nodes.start,e.nodes.end),n=!0),e.f|=w,kn(e,t&&!n),cr(e,0);var r=e.nodes&&e.nodes.t;if(r!==null)for(let e of r)e.stop();On(e),e.f^=w,e.f|=S;var i=e.parent;i!==null&&i.first!==null&&Nn(e),e.next=e.prev=e.teardown=e.ctx=e.deps=e.fn=e.nodes=e.ac=e.b=null}function Mn(e,t){for(;e!==null;){var n=e===t?null:sn(e);e.remove(),e=n}}function Nn(e){var t=e.parent,n=e.prev,r=e.next;n!==null&&(n.next=r),r!==null&&(r.prev=n),t!==null&&(t.first===e&&(t.first=r),t.last===e&&(t.last=n))}function Pn(e,t,n=!0){var r=[];e.f|=256,Fn(e,r,!0);var i=()=>{n&&jn(e),t&&t()},a=r.length;if(a>0){var o=()=>--a||i();for(var s of r)s.out(o)}else i()}function Fn(e,t,n){if(!(e.f&8192)){e.f^=x;var r=e.nodes&&e.nodes.t;if(r!==null)for(let e of r)(e.is_global||n)&&t.push(e);for(var i=e.first;i!==null;){var a=i.next;if(!(i.f&64)){var o=!!(i.f&65536)||!!(i.f&32)&&!!(e.f&16);Fn(i,t,o?n:!1)}i=a}}}function In(e){e.f&=-257,Ln(e,!0)}function Ln(e,t){if(!(e.f&256)&&e.f&8192){e.f^=x,e.f&1024||($e(e,y),At.ensure().schedule(e));for(var n=e.first;n!==null;){var r=n.next,i=!!(n.f&65536)||!!(n.f&32);Ln(n,i?t:!1),n=r}var a=e.nodes&&e.nodes.t;if(a!==null)for(let e of a)(e.is_global||t)&&e.in()}}function Rn(e,t){if(e.nodes)for(var n=e.nodes.start,r=e.nodes.end;n!==null;){var i=n===r?null:sn(n);t.append(n),n=i}}var zn=null,Bn=!1,Vn=!1;function Hn(e){Vn=e}var U=null,Un=!1;function Wn(e){U=e}var W=null;function Gn(e){W=e}var Kn=null;function qn(e){U!==null&&(U.f&2097152||U.f&2)&&(Kn??=new Set).add(e)}var Jn=null,Yn=0,Xn=null;function Zn(e){Xn=e}var Qn=1,$n=0,er=$n;function tr(e){er=e}function nr(){return++Qn}function rr(e){var t=e.f;if(t&2048)return!0;if(t&4096){for(var n=e.deps,r=n.length,i=0;ie.wv)return!0}t&512&&St===null&&$e(e,v)}return!1}function ir(e,t,n=!0){var r=e.reactions;if(r!==null&&!(Kn!==null&&Kn.has(e)))for(var i=0;i{e.ac.abort(fe)}),e.ac=null);try{e.f|=te;var u=e.fn,d=u();e.f|=C;var f=or(e);if(qe()&&Xn!==null&&!Un&&f!==null&&!(e.f&6146))for(var p=0;p0)for(t.length=Yn+Jn.length,r=0;r{s.ac.abort(fe),s.ac=null,$e(s,y)}),vt(s),cr(s,0)}}function cr(e,t){var n=e.deps;if(n!==null)for(var r=t;rn?.call(this,e))}return e.startsWith(`pointer`)||e.startsWith(`touch`)||e===`wheel`?(i.__removed=!1,Xe(()=>{i.__removed||t.addEventListener(e,i,r)})):t.addEventListener(e,i,r),i}function Sr(e,t,n,r,i){var a={capture:r,passive:i},o=xr(e,t,n,a);(t===document.body||t===window||t===document||t instanceof HTMLMediaElement)&&vn(()=>{o.__removed=!0,t.removeEventListener(e,o,a)})}function K(e,t,n){(t[vr]??={})[e]=n}function Cr(e){for(var t=0;t{Tr=!1,wr=null}));var s=0,c=wr===e&&e[vr];if(c){var l=a.indexOf(c);if(l!==-1&&(t===document||t===window)){e[vr]=t;return}var u=a.indexOf(t);if(u===-1)return;l<=u&&(s=l)}if(o=a[s]||e.target,o!==t){i(e,`currentTarget`,{configurable:!0,get(){return o||n}});var d=U,f=W;Wn(null),Gn(null);try{for(var p,m=[];o!==null&&o!==t;){try{var h=o[vr]?.[r];h!=null&&(!o.disabled||e.target===o)&&h.call(o,e)}catch(e){p?m.push(e):p=e}if(e.cancelBubble)break;s++,o=s{throw e});throw p}}finally{e[vr]=t,delete e.currentTarget,Wn(d),Gn(f)}}}var Dr=globalThis?.window?.trustedTypes&&globalThis.window.trustedTypes.createPolicy(`svelte-trusted-html`,{createHTML:e=>e});function Or(e){return Dr?.createHTML(e)??e}function kr(e){var t=un(`template`);return t.innerHTML=Or(e.replaceAll(``,``)),t.content}function Ar(e,t){var n=W;n.nodes===null&&(n.nodes={start:e,end:t,a:null,t:null})}function q(e,t){var n=!!(t&1),r=!!(t&2),i,a=!e.startsWith(``);return()=>{if(A)return Ar(j,null),j;i===void 0&&(i=kr(a?e:``+e),n||(i=on(i)));var t=r||en?document.importNode(i,!0):i.cloneNode(!0);if(n){var o=on(t),s=t.lastChild;Ar(o,s)}else Ar(t,t);return t}}function jr(e,t,n=`svg`){var r=!e.startsWith(``),i=!!(t&1),a=`<${n}>${r?e:``+e}`,o;return()=>{if(A)return Ar(j,null),j;if(!o){var e=on(kr(a));if(i)for(o=document.createDocumentFragment();on(e);)o.appendChild(on(e));else o=on(e)}var t=o.cloneNode(!0);if(i){var n=on(t),r=t.lastChild;Ar(n,r)}else Ar(t,t);return t}}function Mr(e,t){return jr(e,t,`svg`)}function Nr(e=``){if(!A){var t=an(e+``);return Ar(t,t),t}var n=j;return n.nodeType===3?dn(n):(n.before(n=an()),xe(n)),Ar(n,n),n}function Pr(){if(A)return Ar(j,null),j;var e=document.createDocumentFragment(),t=document.createComment(``),n=an();return e.append(t,n),Ar(t,n),e}function J(e,t){if(A){var n=W;(!(n.f&32768)||n.nodes.end===null)&&(n.nodes.end=j),Se();return}e!==null&&e.before(t)}function Fr(){if(A&&j&&j.nodeType===8&&j.textContent?.startsWith(`$`)){let e=j.textContent.substring(1);return Se(),e}return(window.__svelte??={}).uid??=1,`c${window.__svelte.uid++}`}function Ir(e){let t=0,n=Vt(0),r;return()=>{_n()&&(G(n),Tn(()=>(t===0&&(r=pr(()=>e(()=>Jt(n)))),t+=1,()=>{Xe(()=>{--t,t===0&&(r?.(),r=void 0,Jt(n))})})))}}var Lr=T|E;function Rr(e,t,n,r){new zr(e,t,n,r)}var zr=class{parent;is_pending=!1;transform_error;#e;#t=A?j:null;#n;#r;#i;#a=null;#o=null;#s=null;#c=null;#l=0;#u=0;#d=!1;#f=new Set;#p=new Set;#m=null;#h=Ir(()=>(this.#m=Vt(this.#l),()=>{this.#m=null}));constructor(e,t,n,r){this.#e=e,this.#n=t,this.#r=e=>{var t=W;t.b=this,t.f|=128,n(e)},this.parent=W.b,this.transform_error=r??this.parent?.transform_error??(e=>e),this.#i=En(()=>{if(A){let e=this.#t;Se();let t=e.data===`[!`;if(e.data.startsWith(`[?`)){let t=JSON.parse(e.data.slice(2));this.#_(t)}else t?this.#y():this.#g()}else this.#b()},Lr),A&&(this.#e=j)}#g(){try{this.#a=Dn(()=>this.#r(this.#e))}catch(e){this.error(e)}}#_(e){let t=this.#n.failed,{reset:n,invoke_onerror:r}=this.#v(e);Xe(r),t&&(this.#s=Dn(()=>{t(this.#e,()=>e,()=>n)}))}#v(e){var t=!1,n=!1;let r=()=>{if(t){ye();return}t=!0,n&&Be(),this.#s!==null&&Pn(this.#s,()=>{this.#s=null}),this.#S(()=>{this.#b()})};return{reset:r,invoke_onerror:()=>{try{n=!0,this.#n.onerror?.(e,r),n=!1}catch(e){pn(e,this.#i&&this.#i.parent)}}}}#y(){let e=this.#n.pending;e&&(this.is_pending=!0,this.#o=Dn(()=>e(this.#e)),Xe(()=>{var e=this.#c=document.createDocumentFragment(),t=an(),n=!1;if(e.append(t),this.#a=this.#S(()=>{try{return Dn(()=>this.#r(t))}catch(e){try{this.error(e),n=!0}catch(e){pn(e,this.#i.parent)}return null}}),this.#a===null){this.#c=null,n&&this.#x(F);return}this.#u===0&&(this.#e.before(e),this.#c=null,Pn(this.#o,()=>{this.#o=null}),this.#x(F))}))}#b(){try{if(this.is_pending=this.has_pending_snippet(),this.#u=0,this.#l=0,this.#a=Dn(()=>{this.#r(this.#e)}),this.#u>0){var e=this.#c=document.createDocumentFragment();Rn(this.#a,e);let t=this.#n.pending;this.#o=Dn(()=>t(this.#e))}else this.#x(F)}catch(e){this.error(e)}}#x(e){this.is_pending=!1,e.transfer_effects(this.#f,this.#p)}defer_effect(e){tt(e,this.#f,this.#p)}is_rendered(){return!this.is_pending&&(!this.parent||this.parent.is_rendered())}has_pending_snippet(){return!!this.#n.pending}#S(e){var t=W,n=U,r=N;Gn(this.#i),Wn(this.#i),Ue(this.#i.ctx);try{return At.ensure(),e()}finally{Gn(t),Wn(n),Ue(r)}}#C(e,t){if(!this.has_pending_snippet()){this.parent&&this.parent.#C(e,t);return}this.#u+=e,this.#u===0&&(this.#x(t),this.#o&&Pn(this.#o,()=>{this.#o=null}),this.#c&&=(this.#e.before(this.#c),null))}update_pending_count(e,t){this.#C(e,t),this.#l+=e,!(!this.#m||this.#d)&&(this.#d=!0,Xe(()=>{this.#d=!1,this.#m&&Gt(this.#m,this.#l)}))}get_effect_pending(){return this.#h(),G(this.#m)}error(e){if(!this.#n.onerror&&!this.#n.failed)throw e;F?.is_fork?(this.#a&&F.skip_effect(this.#a),this.#o&&F.skip_effect(this.#o),this.#s&&F.skip_effect(this.#s),F.oncommit(()=>{this.#w(e)})):this.#w(e)}#w(e){this.#a&&=(jn(this.#a),null),this.#o&&=(jn(this.#o),null),this.#s&&=(jn(this.#s),null),A&&(xe(this.#t),Ce(),xe(we()));let t=this.#n.failed,n=e=>{let{reset:n,invoke_onerror:r}=this.#v(e);r(),t&&(this.#s=this.#S(()=>{try{return Dn(()=>{var r=W;r.b=this,r.f|=128,t(this.#e,()=>e,()=>n)})}catch(e){return pn(e,this.#i.parent),null}}))};Xe(()=>{var t;try{t=this.transform_error(e)}catch(e){pn(e,this.#i&&this.#i.parent);return}typeof t==`object`&&t&&typeof t.then==`function`?t.then(n,e=>pn(e,this.#i&&this.#i.parent)):n(t)})}};function Y(e,t){var n=t==null?``:typeof t==`object`?`${t}`:t;n!==(e[ue]??=e.nodeValue)&&(e[ue]=n,e.nodeValue=`${n}`)}function Br(e,t){return Hr(e,t)}var Vr=new Map;function Hr(e,{target:t,anchor:n,props:i={},events:a,context:o,intro:s=!0,transformError:c}){rn();var l=void 0,u=Sn(()=>{var s=n??t.appendChild(an());Rr(s,{pending:()=>{}},t=>{We({});var n=N;if(o&&(n.c=o),a&&(i.$$events=a),A&&Ar(t,null),l=e(t,i)||Ke(),A&&(W.nodes.end=j,j===null||j.nodeType!==8||j.data!==`]`))throw _e(),me;Ge()},c);var u=new Set,d=e=>{for(var n=0;n{for(var e of u)for(let n of[t,document]){var r=Vr.get(n),i=r.get(e);--i==0?(n.removeEventListener(e,Er),r.delete(e),r.size===0&&Vr.delete(n)):r.set(e,i)}br.delete(d),s!==n&&s.parentNode?.removeChild(s)}});return Ur.set(l,u),l}var Ur=new WeakMap,Wr=class{anchor;#e=new Map;#t=new Map;#n=new Map;#r=new Set;#i=!0;constructor(e,t=!0){this.anchor=e,this.#i=t}#a=e=>{if(this.#e.has(e)){var t=this.#e.get(e),n=this.#t.get(t);if(n)In(n),this.#r.delete(t);else{var r=this.#n.get(t);r&&(In(r.effect),this.#t.set(t,r.effect),this.#n.delete(t),r.fragment.lastChild.remove(),this.anchor.before(r.fragment),n=r.effect)}for(let[t,n]of this.#e){if(this.#e.delete(t),t===e)break;let r=this.#n.get(n);r&&(jn(r.effect),this.#n.delete(n))}for(let[e,r]of this.#t){if(e===t||this.#r.has(e))continue;let i=()=>{if(Array.from(this.#e.values()).includes(e)){var t=document.createDocumentFragment();Rn(r,t),t.append(an()),this.#n.set(e,{effect:r,fragment:t})}else jn(r);this.#r.delete(e),this.#t.delete(e)};this.#i||!n?(this.#r.add(e),Pn(r,i,!1)):i()}}};#o=e=>{this.#e.delete(e);let t=Array.from(this.#e.values());for(let[e,n]of this.#n)t.includes(e)||(jn(n.effect),this.#n.delete(e))};ensure(e,t){var n=F,r=ln();if(t&&!this.#t.has(e)&&!this.#n.has(e)){if(r){var i=document.createDocumentFragment(),a=an();i.append(a),this.#n.set(e,{effect:Dn(()=>t(a)),fragment:i})}else this.#t.set(e,Dn(()=>t(this.anchor)))}if(this.#e.set(n,e),r){for(let[t,r]of this.#t)t===e?n.unskip_effect(r):n.skip_effect(r);for(let[t,r]of this.#n)t===e?n.unskip_effect(r.effect):n.skip_effect(r.effect);n.oncommit(this.#a),n.ondiscard(this.#o)}else A&&(this.anchor=j),this.#a(n)}};function X(e,t,n=!1){var r;A&&(r=j,Se());var i=new Wr(e),a=n?T:0;function o(e,t){if(A){var n=Te(r);if(e!==parseInt(n.substring(1))){var a=we();xe(a),i.anchor=a,be(!1),i.ensure(e,t),be(!0);return}}i.ensure(e,t)}En(()=>{var e=!1;t((t,n=0)=>{e=!0,o(n,t)}),e||o(-1,null)},a)}var Gr=Symbol(`NaN`);function Kr(e,t,n){A&&Se();var r=new Wr(e),i=!qe();En(()=>{var e=t();e!==e&&(e=Gr),i&&typeof e==`object`&&e&&(e={}),r.ensure(e,n)})}function qr(e,t){return t}function Jr(e,t,n){for(var i=[],a=t.length,o,s=t.length,c=0;c{if(o){if(o.pending.delete(n),o.done.add(n),o.pending.size===0){var t=e.outrogroups;Yr(e,r(o.done)),t.delete(o),t.size===0&&(e.outrogroups=null)}}else--s},!1)}if(s===0){var l=i.length===0&&n!==null&&e.pending.size===0;if(l){var u=n,d=u.parentNode;cn(d),d.append(u),e.items.clear()}Yr(e,t,!l)}else o={pending:new Set(t),done:new Set},(e.outrogroups??=new Set).add(o)}function Yr(e,t,n=!0){var r;if(e.pending.size>0){r=new Set;for(let t of e.pending.values())for(let n of t)r.add(e.items.get(n).e)}for(var i=0;i{var t=i();return e(t)?t:t==null?[]:r(t)}),p,m=new Map,h=!0;function g(e){v.effect.f&16384||(v.pending.delete(e),v.fallback=d,Qr(v,p,c,n,a),d!==null&&(p.length===0?d.f&33554432?(d.f^=D,ei(d,null,c)):In(d):Pn(d,()=>{d=null})))}function _(e){v.pending.delete(e)}var v={effect:En(()=>{p=G(f);var e=p.length;let t=!1;A&&Te(c)===`[!`!=(e===0)&&(c=we(),xe(c),be(!1),t=!0);for(var r=new Set,u=F,v=ln(),y=0;ys(c)):(d=Dn(()=>s(Xr??=an())),d.f|=D)),e>r.size&&je(``,``,``),A&&e>0&&xe(we()),!h){if(m.set(u,r),v){for(let[e,t]of l)r.has(e)||u.skip_effect(t.e);u.oncommit(g),u.ondiscard(_)}else g(u)}t&&be(!0),G(f)}),flags:n,items:l,pending:m,outrogroups:null,fallback:d};h=!1,A&&(c=j)}function Zr(e){for(;e!==null&&!(e.f&32);)e=e.next;return e}function Qr(e,t,n,i,a){var o=!!(i&8),s=t.length,c=e.items,l=Zr(e.effect.first),u,d=null,f,p=[],m=[],h,g,_,v;if(o)for(v=0;v0){var E=i&4&&s===0?n:null;if(o){for(v=0;v{if(f!==void 0)for(_ of f)_.nodes?.a?.apply()})}function $r(e,t,n,r,i,a,o,s){var c=o&1?o&16?Vt(n):Ht(n,!1,!1):null,l=o&2?Vt(i):null;return{v:c,i:l,e:Dn(()=>(a(t,c??n,l??i,s),()=>{e.delete(r)}))}}function ei(e,t,n){if(e.nodes)for(var r=e.nodes.start,i=e.nodes.end,a=t&&!(t.f&33554432)?t.nodes.start:n;r!==null;){var o=sn(r);if(a.before(r),r===i)return;r=o}}function ti(e,t,n){t===null?e.effect.first=n:t.next=n,n===null?e.effect.last=t:n.prev=t}function ni(e,t,n){var r;A&&(r=j,Se());var i=new Wr(e);En(()=>{var e=t()??null;if(A&&Te(r)===`[`!=(e!==null)){var a=we();xe(a),i.anchor=a,be(!1),i.ensure(e,e&&(t=>n(t,e))),be(!0);return}i.ensure(e,e&&(t=>n(t,e)))},T)}var ri=[...` +\r\f\xA0\v`];function ii(e,t,n){var r=e==null?``:``+e;if(t&&(r=r?r+` `+t:t),n){for(var i of Object.keys(n))if(n[i])r=r?r+` `+i:i;else if(r.length)for(var a=i.length,o=0;(o=r.indexOf(i,o))>=0;){var s=o+a;(o===0||ri.includes(r[o-1]))&&(s===r.length||ri.includes(r[s]))?r=(o===0?``:r.substring(0,o))+r.substring(s+1):o=s}}return r===``?null:r}function ai(e,t=!1){var n=t?` !important;`:`;`,r=``;for(var i of Object.keys(e)){var a=e[i];a!=null&&a!==``&&(r+=` `+i+`: `+a+n)}return r}function oi(e){return e[0]!==`-`||e[1]!==`-`?e.toLowerCase():e}function si(e,t){if(t){var n=``,r,i;if(Array.isArray(t)?(r=t[0],i=t[1]):r=t,e){e=String(e).replaceAll(/\/\*.*?\*\//g,``).trim();var a=!1,o=0,s=!1,c=[];r&&c.push(...Object.keys(r).map(oi)),i&&c.push(...Object.keys(i).map(oi));var l=0,u=-1;let t=e.length;for(var d=0;d{t.every(_i)||(`__defaultValue`in e&&fi(e,!1),`__value`in e&&pi(e,e.__value))});t.observe(e,{childList:!0,subtree:!0,attributes:!0,attributeFilter:[`value`]}),vn(()=>{t.disconnect()})}function hi(e,t,n=t){var r=new WeakSet,i=!0;ot(e,`change`,t=>{var i=t?`[selected]`:`:checked`,a;if(e.multiple)a=[].map.call(e.querySelectorAll(i),gi);else{var o=e.querySelector(i)??e.querySelector(`option:not([disabled])`);a=o&&gi(o)}n(a),e.__value=a,F!==null&&r.add(F)}),Cn(()=>{var a=t();if(e===document.activeElement){var o=F;if(r.has(o))return}if(pi(e,a,i),i&&a===void 0){var s=e.querySelector(`:checked`);s!==null&&(a=gi(s),n(a))}e.__value=a,i=!1})}function gi(e){return`__value`in e?e.__value:e.value}function _i(e){if(e.target.closest(`selectedcontent`)!==null)return!0;if(e.type===`childList`){var t=[...e.addedNodes,...e.removedNodes];return t.length>0&&t.every(e=>e.nodeName===`SELECTEDCONTENT`)}return!1}var vi=Symbol(`is custom element`),yi=Symbol(`is html`),bi=pe?`link`:`LINK`;function xi(e){if(A){var t=!1,n=()=>{if(!t){if(t=!0,e.hasAttribute(`value`)){var n=e.value;Q(e,`value`,null),e.value=n}if(e.hasAttribute(`checked`)){var r=e.checked;Q(e,`checked`,null),e.checked=r}}};e[de]=n,Xe(n),it()}}function Q(e,t,n,r){var i=Si(e);A&&(i[t]=e.getAttribute(t),t===`src`||t===`srcset`||t===`href`&&e.nodeName===bi)||i[t]!==(i[t]=n)&&(t===`loading`&&(e[oe]=n),n==null?e.removeAttribute(t):typeof n!=`string`&&wi(e).has(t)?e[t]=n:e.setAttribute(t,n))}function Si(e){return e[se]??={[vi]:e.nodeName.includes(`-`),[yi]:e.namespaceURI===he}}var Ci=new Map;function wi(e){var t=e.getAttribute(`is`)||e.nodeName,n=Ci.get(t);if(n)return n;Ci.set(t,n=new Set);for(var r,i=e,a=Element.prototype;a!==i;){for(var s in r=o(i),r)r[s].set&&s!==`innerHTML`&&s!==`textContent`&&s!==`innerText`&&n.add(s);i=l(i)}return n}function Ti(e,t,n=t){var r=new WeakSet;ot(e,`input`,async i=>{var a=i?e.defaultValue:e.value;if(a=ki(e)?Ai(a):a,n(a),F!==null&&r.add(F),await ur(),a!==(a=t())){var o=e.selectionStart,s=e.selectionEnd,c=e.value.length;if(e.value=a??``,s!==null){var l=e.value.length;o===s&&s===c&&l>c?(e.selectionStart=l,e.selectionEnd=l):(e.selectionStart=o,e.selectionEnd=Math.min(s,l))}}}),(A&&e.defaultValue!==e.value||pr(t)==null&&e.value)&&(n(ki(e)?Ai(e.value):e.value),F!==null&&r.add(F)),Tn(()=>{var n=t();if(e===document.activeElement){var i=F;if(r.has(i))return}ki(e)&&n===Ai(e.value)||(e.type!==`date`||n||e.value)&&n!==e.value&&(e.value=n??``)})}var Ei=new Set;function Di(e,t,n,r,i=r){var a=n.getAttribute(`type`)===`checkbox`,o=e;let s=!1;if(t!==null)for(var c of t)o=o[c]??=[];o.push(n),ot(n,`change`,()=>{var e=n.__value;a&&(e=Oi(o,e,n.checked)),i(e)},()=>i(a?[]:null)),Tn(()=>{var e=r();if(A&&n.defaultChecked!==n.checked){s=!0;return}a?(e||=[],n.checked=e.includes(n.__value)):n.checked=Qt(n.__value,e)}),vn(()=>{var e=o.indexOf(n);e!==-1&&o.splice(e,1)}),Ei.has(o)||(Ei.add(o),Xe(()=>{o.sort((e,t)=>e.compareDocumentPosition(t)===4?-1:1),Ei.delete(o)})),Xe(()=>{if(s){var e=a?Oi(o,e,n.checked):o.find(e=>e.checked)?.__value;i(e)}})}function Oi(e,t,n){for(var r=new Set,i=0;i{var n=this.#e.get(e);n.delete(t),n.size===0&&(this.#e.delete(e),this.#t.unobserve(e))}}#r(){return this.#t??=new ResizeObserver(t=>{for(var n of t){e.entries.set(n.target,n);for(var r of this.#e.get(n.target)||[])r(n)}})}}({box:`border-box`});function Mi(e,t,n){var r=ji.observe(e,()=>n(e[t]));Cn(()=>(pr(()=>n(e[t])),r))}function Ni(e,t){return e===t||e?.[O]===t}function Pi(e=Ke(),t,n,r){var i=N.r,a=W;return Cn(()=>{var o,s;return Tn(()=>{o=s,s=r?.()||[],pr(()=>{Ni(n(...s),e)||(t(e,...s),o&&Ni(n(...o),e)&&t(null,...o))})}),()=>{let r=a;for(;r!==i&&r.parent!==null&&r.parent.f&33554432;)r=r.parent;let o=()=>{s&&Ni(n(...s),e)&&t(null,...s)},c=r.teardown;r.teardown=()=>{o(),c?.()}}}),e}function Fi(e,t,n,r,i){var a=()=>{r(n[e])};n.addEventListener(t,a),i?Tn(()=>{n[e]=i()}):a(),(n===document.body||n===window||n===document)&&vn(()=>{n.removeEventListener(t,a)})}function Ii(e=!1){let t=N,n=t.l.u;if(!n)return;let r=()=>mr(t.s);if(e){let e=0,n={},i=dt(()=>{let r=!1,i=t.s;for(let e in i)i[e]!==n[e]&&(n[e]=i[e],r=!0);return r&&e++,e});r=()=>G(i)}n.b.length&&xn(()=>{Li(t,r),m(n.b)}),yn(()=>{let e=pr(()=>n.m.map(p));return()=>{for(let t of e)typeof t==`function`&&t()}}),n.a.length&&yn(()=>{Li(t,r),m(n.a)})}function Li(e,t){if(e.l.s)for(let t of e.l.s)G(t);t()}var Ri=!1;function zi(e){var t=Ri;try{return Ri=!1,[e(),Ri]}finally{Ri=t}}var Bi={get(e,t){let n=e.props.length;for(;n--;){let r=e.props[n];if(d(r)&&(r=r()),typeof r==`object`&&r&&t in r)return r[t]}},set(e,t,n){let r=e.props.length;for(;r--;){let i=e.props[r];d(i)&&(i=i());let o=a(i,t);if(o&&o.set)return o.set(n),!0}return!1},getOwnPropertyDescriptor(e,t){let n=e.props.length;for(;n--;){let r=e.props[n];if(d(r)&&(r=r()),typeof r==`object`&&r&&t in r){let e=a(r,t);return e&&!e.configurable&&(e.configurable=!0),e}}},has(e,t){if(t===O||t===ae)return!1;for(let n of e.props)if(d(n)&&(n=n()),n!=null&&t in n)return!0;return!1},ownKeys(e){let t=[];for(let n of e.props)if(d(n)&&(n=n()),n){for(let e in n)t.includes(e)||t.push(e);for(let e of Object.getOwnPropertySymbols(n))t.includes(e)||t.push(e)}return t}};function Vi(...e){return new Proxy({props:e},Bi)}function Hi(e,t,n,r){var i=!Ve||!!(n&2),o=!!(n&8),s=!!(n&16),c=r,l=!0,u=void 0,d=()=>s&&i?(u??=dt(r),G(u)):(l&&(l=!1,c=s?pr(r):r),c);let f;if(o){var p=O in e||ae in e;f=a(e,t)?.set??(p&&t in e?n=>e[t]=n:void 0)}var m,h=!1;o?[m,h]=zi(()=>e[t]):m=e[t],m===void 0&&r!==void 0&&(m=d(),f&&(i&&Ie(t),f(m)));var g=i?()=>{var n=e[t];return n===void 0?d():(l=!0,n)}:()=>{var n=e[t];return n!==void 0&&(c=void 0),n===void 0?c:n};if(i&&!(n&4))return g;if(f){var _=e.$$legacy;return(function(e,t){return arguments.length>0?((!i||!t||_||h)&&f(t?g():e),e):g()})}var v=!1,y=(n&1?dt:mt)(()=>(v=!1,g()));o&&G(y);var b=W;return(function(e,t){if(arguments.length>0){let n=t?G(y):i&&o?Xt(e):e;return L(y,n),v=!0,c!==void 0&&(c=n),e}return Vn&&v||b.f&16384?y.v:G(y)})}function Ui(e){N===null&&ke(`onMount`),Ve&&N.l!==null?Wi(N).m.push(e):yn(()=>{let t=pr(e);if(typeof t==`function`)return t})}function Wi(e){var t=e.l;return t.u??={a:[],b:[],m:[]}}typeof window<`u`&&((window.__svelte??={}).v??=new Set).add(`5`);function Gi(e,t){if(e instanceof RegExp)return{keys:!1,pattern:e};var n,r,i,a,o=[],s=``,c=e.split(`/`);for(c[0]||c.shift();i=c.shift();)n=i[0],n===`*`?(o.push(`wild`),s+=`/(.*)`):n===`:`?(r=i.indexOf(`?`,1),a=i.indexOf(`.`,1),o.push(i.substring(1,~r?r:~a?a:i.length)),s+=~r&&!~a?`(?:/([^/]+?))?`:`/([^/]+?)`,~a&&(s+=(~r?`?`:``)+`\\`+i.substring(a))):s+=`/`+i;return{keys:o,pattern:RegExp(`^`+s+(t?`(?=$|/)`:`/?$`),`i`)}}var Ki=new class{#e=I(qi());get _loc(){return G(this.#e)}set _loc(e){L(this.#e,e)}#t=P(()=>this._loc.location);get _location(){return G(this.#t)}set _location(e){L(this.#t,e)}#n=P(()=>this._loc.querystring);get _querystring(){return G(this.#n)}set _querystring(e){L(this.#n,e)}#r=I(void 0);get _params(){return G(this.#r)}set _params(e){L(this.#r,e)}get loc(){return this._loc}get location(){return this._location}get querystring(){return this._querystring}get params(){return this._params}constructor(){typeof window<`u`?window.addEventListener(`hashchange`,()=>{this._loc=qi()}):console.warn(`[svelte-spa-router] window 'window' is not defined, skipping initiation`)}};function qi(){let e=typeof window<`u`?window.location.href:``,t=e.indexOf(`#/`),n=t>-1?e.substr(t+1):`/`,r=n.indexOf(`?`),i=``;return r>-1&&(i=n.substr(r+1),n=n.substr(0,r)),{location:n,querystring:i}}function Ji(e){e?window.scrollTo(e.__svelte_spa_router_scrollX||0,e.__svelte_spa_router_scrollY||0):window.scrollTo(0,0)}function Yi(e,t){We(t,!0);let n=Hi(t,`routes`,19,()=>({})),r=Hi(t,`prefix`,3,``),i=Hi(t,`restoreScrollState`,3,!1),a=Hi(t,`onConditionsFailed`,3,()=>{}),o=Hi(t,`onRouteLoaded`,3,()=>{}),s=Hi(t,`onRouteLoading`,3,()=>{}),c=Hi(t,`onRouteEvent`,3,()=>{});class l{path;component;conditions;userData;props;_pattern;_keys;constructor(e,t){let n=e=>typeof e==`object`&&!!e&&e._sveltesparouter===!0;if(!t||typeof t!=`function`&&!n(t))throw Error(`Invalid component object`);if(!e||typeof e==`string`&&(e.length<1||e.charAt(0)!=`/`&&e.charAt(0)!=`*`)||typeof e==`object`&&!(e instanceof RegExp))throw Error(`Invalid value for "path" argument - strings must start with / or *`);let r=Gi(e);if(this.path=e,n(t)){let e=t;this.component=e.component,this.conditions=e.conditions||[],this.userData=e.userData,this.props=e.props||{}}else{let e=t;this.component=()=>Promise.resolve(e),this.conditions=[],this.props={}}this._pattern=r.pattern,this._keys=r.keys}match(e){if(r()){if(typeof r()==`string`){if(e.startsWith(r()))e=e.substr(r().length)||`/`;else return null}else if(r()instanceof RegExp){let t=e.match(r());if(t&&t[0])e=e.substr(t[0].length)||`/`;else return null}}let t=this._pattern.exec(e);if(t===null)return null;if(this._keys===!1)return t;let n={},i=0;for(;i{u.push(new l(t,e))}):Object.keys(n()).forEach(e=>{let t=n();u.push(new l(e,t[e]))});let d=I(null),f=I(null),p=I({}),m=null,h=null;yn(()=>{history.scrollRestoration=i()?`manual`:`auto`}),yn(()=>{if(!i())return;let e=e=>{m=e.state&&(e.state.__svelte_spa_router_scrollY||e.state.__svelte_spa_router_scrollX)?e.state:null};return window.addEventListener(`popstate`,e),()=>window.removeEventListener(`popstate`,e)});async function g(e,t){await ur(),e(t)}yn(()=>{let e=Ki.loc,t=!1;return pr(async()=>{let n=0;for(;n{t=!0}});function _(e){return e&&typeof e==`object`&&Object.keys(e).length?e:null}var v=Pr(),y=z(v),b=e=>{let t=P(()=>G(d));var n=Pr(),r=z(n),i=e=>{var n=Pr();ni(z(n),()=>G(t),(e,t)=>{t(e,Vi({get params(){return G(f)},get onRouteEvent(){return c()}},()=>G(p)))}),J(e,n)},a=e=>{var n=Pr();ni(z(n),()=>G(t),(e,t)=>{t(e,Vi({get onRouteEvent(){return c()}},()=>G(p)))}),J(e,n)};X(r,e=>{G(f)?e(i):e(a,-1)}),J(e,n)};X(y,e=>{G(d)&&e(b)}),J(e,v),Ge()}var Xi=[`bars`,`pace`,`zone`,`pie`,`fuel`,`resets`,`thresholds`],Zi=`codexometer.web.preferences.v1`,Qi={tab:`quota`,view:`bars`,selected:``,defaultDetail:0,layouts:[]};function $i(){try{let e=JSON.parse(localStorage.getItem(Zi)||`null`);return!e||typeof e!=`object`?Qi:{tab:[`quota`,`sessions`,`usage`].includes(e.tab)?e.tab:`quota`,view:Xi.includes(e.view)?e.view:`bars`,selected:typeof e.selected==`string`?e.selected.slice(0,256):``,defaultDetail:+(e.defaultDetail===1),layouts:Array.isArray(e.layouts)?e.layouts.filter(e=>{if(!e||typeof e!=`object`)return!1;let t=e;return typeof t.id==`string`&&t.id.length<=256&&[0,1,2].includes(t.level)}).slice(-100):[]}}catch{return Qi}}var ea=Xt($i());function ta(){let e=JSON.stringify(ea);try{localStorage.setItem(Zi,e)}catch{}}function na(){return ea.tab===`quota`?`/quota/`+ea.view:`/`+ea.tab}function ra(e){return ea.layouts.find(t=>t.id===e)?.level??ea.defaultDetail}function ia(e){ea.defaultDetail=e,ea.layouts=[]}function aa(e,t){ea.layouts=[...ea.layouts.filter(t=>t.id!==e),{id:e,level:Math.max(0,Math.min(2,t))}].slice(-100)}var $=Xt({data:null,connected:!1,error:``}),oa=e=>e==null?`Unavailable`:e.toLocaleString(`en-GB`),sa=e=>!e||String(e).startsWith(`0001-`)?`Not yet available`:new Date(typeof e==`number`?e*1e3:e).toLocaleString(`en-GB`),ca=`codexometer.web.session`,la=class extends Error{},ua;async function da(e,t,n){if(!ua||!$.connected||!$.data?.control||$.data.sessionsError)throw Error(`Session controls unavailable. Check the live connection.`);return await ua(e,t,n)}function fa(){let e=new AbortController,t,n=``;ua=async(t,r,i)=>{let a=await fetch(`/api/control/`+t,{method:`POST`,headers:{Authorization:`Bearer ${n}`,"Content-Type":`application/json`},body:JSON.stringify(r),signal:AbortSignal.any([e.signal,AbortSignal.timeout(8e3),...i?[i]:[]]),cache:`no-store`});if(!a.ok)throw a.status===502?Error(`Outcome uncertain. Check Codex before taking another action; nothing was retried.`):new la(`Action rejected, expired or changed. Refresh and check Codex; nothing was retried.`);return a.json()};try{n=sessionStorage.getItem(ca)||``}catch{}let r=location.hash,i=r.startsWith(`#pair=`)?r.slice(6):``;(i||!r||r===`#`)&&(history.replaceState(null,``,`/#`+na()),window.dispatchEvent(new HashChangeEvent(`hashchange`)));async function a(){if(i)try{let t=await fetch(`/api/pair`,{method:`POST`,headers:{"Content-Type":`application/json`},body:JSON.stringify({secret:i}),signal:e.signal});if(!t.ok)throw Error(`Pairing link expired or already used. Restart --web for a fresh link.`);n=(await t.json()).token;try{sessionStorage.setItem(ca,n)}catch{}}catch(t){e.signal.aborted||($.error=t instanceof Error?t.message:`Pairing failed`);return}if(!n){$.error=`Open the private pairing link printed by codexometer --web in your terminal.`;return}await o()}async function o(){try{let t=await fetch(`/api/events`,{headers:{Authorization:`Bearer ${n}`},signal:e.signal,cache:`no-store`});if(t.status===401){try{sessionStorage.removeItem(ca)}catch{}$.error=`Browser session expired. Restart --web and open its new pairing link.`;return}if(!t.ok||!t.body)throw Error(`Live connection unavailable`);let r=t.body.getReader(),i=new TextDecoder,a=``;for(;!e.signal.aborted;){let{done:e,value:t}=await r.read();if(e)break;a+=i.decode(t,{stream:!0});let n;for(;(n=a.indexOf(` + +`))>=0;){let e=a.slice(0,n);a=a.slice(n+2),e.startsWith(`data: `)&&($.data=JSON.parse(e.slice(6)),$.connected=!0,$.error=``)}}}catch{}finally{$.connected=!1}e.signal.aborted||($.error=`Connection lost — showing last observation. Reconnecting…`,t=setTimeout(o,2500))}return a(),()=>{ua=void 0,e.abort(),clearTimeout(t),$.connected=!1}}He();var pa=q(`

      Quota refresh failed. Policy state is based on the last successful + observation.

      `),ma=q(`

      `),ha=q(`

      The longest Codex quota window selects one active model profile. ASK + creates a per-session review; AUTO applies the profile on the next + eligible check.

      `,1),ga=q(`

      No threshold-based model steps were configured at launch.

      `);function _a(e,t){We(t,!1),Ii();var n=Pr(),r=z(n),i=e=>{var t=ha(),n=z(t),r=B(n),i=V(n,2),a=e=>{J(e,pa())};X(i,e=>{$.data.quotaError&&e(a)});var o=V(i,2),s=R(o),c=B(s),l=V(s,4);Z(l,5,()=>$.data.thresholds,qr,(e,t)=>{var n=ma();let r;var i=R(n),a=B(i),o=V(i,2),s=R(o),c=B(s,!0),l=B(V(s,2));M(o);var u=V(o,2),d=B(u,!0),f=B(V(u,2));M(n),H((e,i)=>{r=ci(n,1,``,null,r,{active:G(t).state===`ACTIVE`,next:G(t).state===`NEXT`}),Y(a,`${G(t).threshold??``}%`),Y(c,G(t).model),Y(l,`${e??``} REASONING // ${i??``} + SPEED`),Y(d,G(t).mode),Y(f,`${G(t).state??``}${G(t).state===`NEXT`?` // ${G(t).remaining||0} PP TO GO`:``}`)},[()=>G(t).effort.toUpperCase(),()=>G(t).speed.toUpperCase()]),J(e,n)}),M(l),M(o),H(e=>{Y(r,`MODEL STEP POLICY // OBSERVED ${e??``}`),Y(c,`THRESHOLDS // ${$.data.thresholds.length??``} CONFIGURED`)},[()=>sa($.data.quotaAt)]),J(e,t)},a=e=>{J(e,ga())};X(r,e=>{$.data?.thresholds?.length?e(i):e(a,-1)}),J(e,n),Ge()}var va=Mr(` `,1),ya=Mr(` `,1),ba=q(` `),xa=q(`
      Quota observations
      Observed atPeriod elapsedConsumedTrail segment
      `),Sa=q(`


      Observed quota + path, not individual session usage. Gaps are not interpolated. Expand the + observation table for times, positions and gaps.

      OBSERVATION TABLE
      `,1),Ca=q(`
      CONSUMPTIONQUOTA PERIOD ELAPSED


      `,1);function wa(e,t){let n=Fr();We(t,!0);let r=Hi(t,`trail`,19,()=>[]),i=I(!1),a=[0,25,50,75,100],o=I(400),s=I(240),c=P(()=>G(o)-24),l=P(()=>G(s)-48),u=P(()=>Math.max(1,G(c)-48)),d=P(()=>Math.max(1,G(l)-20)),f=P(()=>48+Math.max(0,Math.min(100,t.elapsed))/100*G(u)),p=P(()=>G(l)-Math.max(0,Math.min(100,t.used))/100*G(d)),m=P(()=>t.used-t.elapsed),h=P(()=>r().map((e,t)=>`${t===0||e.break?`M`:`L`}${48+e.elapsed/100*G(u)} ${G(l)-e.used/100*G(d)}`).join(` `));var g=Ca(),_=z(g),v=R(_),y=R(v),b=B(y),x=V(y),S=V(x);Z(S,17,()=>a,qr,(e,t)=>{var n=va(),r=z(n),i=V(r),a=V(i),o=B(a),s=V(a),f=B(s);H(()=>{Q(r,`x1`,48+G(t)/100*G(u)),Q(r,`x2`,48+G(t)/100*G(u)),Q(r,`y2`,G(l)),Q(i,`y1`,G(l)-G(t)/100*G(d)),Q(i,`x2`,G(c)),Q(i,`y2`,G(l)-G(t)/100*G(d)),Q(a,`x`,48+G(t)/100*G(u)),Q(a,`y`,G(l)+20),Y(o,`${G(t)??``}%`),Q(s,`y`,G(l)+4-G(t)/100*G(d)),Y(f,`${G(t)??``}%`)}),J(e,n)});var C=V(S),w=V(C),T=V(w),E=e=>{var t=ya(),n=z(t),i=V(n),a=B(R(i));M(i),H(e=>{Q(n,`d`,G(h)),Q(i,`cx`,48+r()[0].elapsed/100*G(u)),Q(i,`cy`,G(l)-r()[0].used/100*G(d)),Y(a,`First observation: ${e??``}`)},[()=>sa(r()[0].at)]),J(e,t)};X(T,e=>{r().length&&e(E)});var ee=V(T,2),D=V(ee),te=V(D),ne=B(R(te));M(te),M(v),M(_);var re=V(_,2),O=R(re),ie=B(V(O,3),!0);M(re);var ae=V(re,2),oe=e=>{var t=Sa(),a=z(t),o=R(a);Ce(2),M(a);var s=V(a,2),c=V(R(s),2),l=e=>{var t=xa(),n=R(t),i=V(R(n),2);Z(i,21,r,qr,(e,t,n)=>{var r=ba(),i=R(r),a=R(i),o=B(a,!0);M(i);var s=V(i),c=B(s),l=V(s),u=B(l),d=B(V(l),!0);M(r),H((e,r)=>{Q(a,`datetime`,G(t).at),Y(o,e),Y(c,`${r??``}%`),Y(u,`${G(t).used??``}%`),Y(d,n===0?`First observation`:G(t).break?`Gap before this observation`:`Connected to previous observation`)},[()=>sa(G(t).at),()=>G(t).elapsed.toFixed(1)]),J(e,r)}),M(i),M(n),M(t),J(e,t)};X(c,e=>{G(i)&&e(l)}),M(s),H(e=>{Q(a,`id`,n+`-trail-summary`),Y(o,`○ START ${e??``} // ${r().length??``} + ${r().length===1?`OBSERVATION`:`OBSERVATIONS`}`)},[()=>sa(r()[0].at)]),Fi(`open`,`toggle`,s,e=>L(i,e),()=>G(i)),J(e,t)};X(ae,e=>{r().length&&e(oe)}),H((e,i,a,m)=>{Q(v,`viewBox`,`0 0 ${G(o)} ${G(s)}`),Q(v,`aria-describedby`,r().length?n+`-trail-summary`:void 0),Q(v,`aria-label`,e),Q(b,`id`,n),Q(x,`width`,G(u)),Q(x,`height`,G(d)),Q(x,`fill`,`url(#${n})`),Q(C,`d`,`M48 20 V${G(l)} H${G(c)}`),Q(w,`y1`,G(l)),Q(w,`x2`,G(c)),Q(ee,`x`,48+G(u)/2),Q(ee,`y`,G(s)-5),Q(D,`cx`,G(f)),Q(D,`cy`,G(p)),Q(te,`cx`,G(f)),Q(te,`cy`,G(p)),Y(ne,`${t.used??``}% consumed // ${i??``}% of period elapsed`),Y(O,`${t.used??``}% USED // ${a??``}% TIME ELAPSED`),Y(ie,m)},[()=>`Consumption zone: ${t.used}% consumed, ${t.elapsed.toFixed(1)}% of quota period elapsed. ${G(m)>0?`Above`:G(m)<0?`Below`:`On`} the steady-consumption line.`,()=>t.elapsed.toFixed(1),()=>t.elapsed.toFixed(1),()=>Math.abs(G(m))<.05?`ON PACE`:G(m)>0?`ABOVE THE LINE — CONSUMING FASTER THAN TIME`:`BELOW THE LINE — WITHIN PACE`]),Mi(_,`clientWidth`,e=>L(o,e)),Mi(_,`clientHeight`,e=>L(s,e)),J(e,g),Ge()}var Ta=q(` `),Ea=q(`

      Quota refresh failed. Values below are the last successful observation.

      `),Da=q(`

      Expiry details unavailable. No listed expiry does not mean no expiry.

      `),Oa=q(`

      `),ka=q(`

      Read-only preview. Use the terminal to redeem a reset.

      The backend may return only some credits. This list does not establish + redemption order.

      `),Aa=q(` `,1),ja=Mr(``),Ma=Mr(``),Na=q(`
      `),Pa=q(`

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

      `),Fa=q(`
      ▼
      −100 // OVER BUDGET+100 // HEADROOM

      `,1),Ia=q(`

      Cycle duration unavailable — pace cannot be calculated.

      `),La=q(`
      EMPTYFULL
      `),Ra=q(`

      `,1),za=q(`
      `,1),Ba=q(`

      `),Va=q(`

      `),Ha=q(`

      No quota windows reported yet.

      `),Ua=q(`
      `,1),Wa=q(`

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

      `,1),Ga=q(` `,1);function Ka(e,t){We(t,!0);let n=Hi(t,`params`,19,()=>({})),r=P(()=>Xi.filter(e=>e!==`thresholds`||!!$.data?.thresholds?.length)),i=I(Xt(Date.now())),a=P(()=>G(r).includes(n().view||``)?n().view:`bars`);Ui(()=>{let e=setInterval(()=>L(i,Date.now(),!0),1e3);return()=>clearInterval(e)});function o(e){return!e.duration||e.duration<=0||!e.reset?null:Math.max(0,Math.min(100,100*(1-(e.reset*1e3-G(i))/(e.duration*6e4))))}yn(()=>{ea.view=G(a)});function s(e){let t=e/100*Math.PI*2;return`M60 60 L60 14 A46 46 0 ${+(e>50)} 1 ${60+46*Math.sin(t)} ${60-46*Math.cos(t)} Z`}var c=Ga(),l=z(c);Z(l,21,()=>G(r),qr,(e,t)=>{var n=Ta();let r;var i=B(n,!0);H(e=>{Q(n,`href`,`#/quota/`+G(t)),Q(n,`aria-current`,G(a)===G(t)?`page`:void 0),r=ci(n,1,``,null,r,{active:G(a)===G(t)}),Y(i,e)},[()=>G(t)===`pace`?`CONSUMPTION PACE`:G(t)===`zone`?`CONSUMPTION ZONE`:G(t)===`fuel`?`FUEL TANK`:G(t).toUpperCase()]),J(e,n)}),M(l);var u=V(l,2),d=e=>{var t=Wa(),n=z(t),r=e=>{J(e,Ea())};X(n,e=>{$.data.quotaError&&e(r)});var i=V(n,2),c=B(i),l=V(i,2),u=e=>{_a(e,{})},d=e=>{var t=ka(),n=R(t),r=B(n),i=V(n,4),a=e=>{J(e,Da())};X(i,e=>{$.data.credits.length||e(a)}),Z(V(i,2),17,()=>$.data.credits,qr,(e,t)=>{var n=Oa(),r=R(n),i=B(r),a=B(V(r,2),!0);M(n),H(e=>{Y(i,`${(G(t).title||`Quota reset`)??``} // ${G(t).status??``}`),Y(a,e)},[()=>G(t).expiryKnown?G(t).expires?`EXPIRES `+sa(G(t).expires):`Does not expire`:`Expiry information unavailable`]),J(e,n)}),Ce(2),M(t),H(()=>Y(r,`RESET INVENTORY // ${$.data.creditCount??``} AVAILABLE`)),J(e,t)},f=e=>{var t=Ua(),n=z(t);let r;Z(n,21,()=>$.data.meters,qr,(e,t)=>{let n=P(()=>o(G(t))),r=P(()=>G(n)===null?null:G(n)-G(t).used);var i=Va(),c=R(i),l=B(c,!0),u=V(c,2),d=R(u),f=e=>{var n=Aa(),r=z(n),i=B(r),a=B(V(r));H(()=>{Y(i,`FREE ${100-G(t).used}%`),Y(a,`USED ${G(t).used??``}%`)}),J(e,n)},p=e=>{var n=Aa(),r=z(n),i=B(r),a=B(V(r));H(()=>{Y(i,`USED ${G(t).used??``}%`),Y(a,`FREE ${100-G(t).used}%`)}),J(e,n)};X(d,e=>{G(a)===`fuel`?e(f):e(p,-1)}),M(u);var m=V(u,2);let h;var g=R(m),_=e=>{var n=Na(),r=R(n),i=V(R(r)),a=e=>{J(e,ja())},o=e=>{var n=Ma();H(e=>Q(n,`d`,e),[()=>s(G(t).used)]),J(e,n)};X(i,e=>{G(t).used>=100?e(a):G(t).used>0&&e(o,1)}),M(r),M(n),H(()=>Q(r,`aria-label`,`${G(t).used}% quota used`)),J(e,n)},v=e=>{var r=Pr(),i=z(r),a=e=>{{let r=P(()=>G(t).trail||[]);wa(e,{get used(){return G(t).used},get elapsed(){return G(n)},get trail(){return G(r)}})}},o=e=>{J(e,Pa())};X(i,e=>{G(n)===null?e(o,-1):e(a)}),J(e,r)},y=e=>{var t=Pr(),n=z(t),i=e=>{var t=Fa(),n=z(t),i=V(R(n),2);let a;M(n);var o=V(n,4),s=R(o),c=B(V(s),!0);M(o),H(e=>{a=ui(i,``,a,{left:`${(G(r)+100)/2}%`}),Y(s,`${G(r)>=0?`+`:``}${e??``} PP `),Y(c,G(r)>=0?`WITHIN PACE`:`USING FASTER THAN TIME`)},[()=>G(r).toFixed(1)]),J(e,t)},a=e=>{J(e,Ia())};X(n,e=>{G(r)===null?e(a,-1):e(i)}),J(e,t)},b=e=>{var r=za(),i=z(r),o=R(i);let s;M(i);var c=V(i,2),l=e=>{J(e,La())};X(c,e=>{G(a)===`fuel`&&e(l)});var u=V(c,2),d=e=>{var t=Ra(),r=z(t),i=B(r),o=V(r,2),s=R(o);let c;M(o),H(e=>{Y(i,`RESET CYCLE // ${e??``}% ELAPSED`),c=ui(s,``,c,{width:`${G(a)===`fuel`?100-G(n):G(n)}%`})},[()=>Math.floor(G(n))]),J(e,t)};X(u,e=>{G(n)!==null&&e(d)}),H(()=>{Q(i,`aria-label`,G(a)===`fuel`?`Fuel remaining`:`Quota used`),Q(i,`aria-valuenow`,G(a)===`fuel`?100-G(t).used:G(t).used),s=ui(o,``,s,{width:`${G(a)===`fuel`?100-G(t).used:G(t).used}%`})}),J(e,r)};X(g,e=>{G(a)===`pie`?e(_):G(a)===`zone`?e(v,1):G(a)===`pace`?e(y,2):e(b,-1)}),M(m);var x=V(m,2),S=B(x),C=V(x,2),w=e=>{var n=Ba(),r=B(n,!0);H(()=>Y(r,G(t).details)),J(e,n)};X(C,e=>{G(t).details&&e(w)}),M(i),H(e=>{Y(l,G(t).name),h=ci(m,1,`meter-graphic`,null,h,{"bar-graphic":G(a)===`bars`||G(a)===`fuel`}),Y(S,`RESETS // ${e??``}`)},[()=>sa(G(t).reset)]),J(e,i)}),M(n);var i=V(n,2),c=e=>{J(e,Ha())};X(i,e=>{$.data.meters.length||e(c)}),H(()=>r=ci(n,1,`quota-grid`,null,r,{radial:G(a)===`pie`,zone:G(a)===`zone`})),J(e,t)};X(l,e=>{G(a)===`thresholds`?e(u):G(a)===`resets`?e(d,1):e(f,-1)}),Ce(2),H(e=>Y(c,`QUOTA // OBSERVED ${e??``}`),[()=>sa($.data.quotaAt)]),J(e,t)};X(u,e=>{$.data&&e(d)}),J(e,c),Ge()}var qa=q(`
      `),Ja=q(`

      `,1);function Ya(e,t){We(t,!0);let n=Hi(t,`values`,19,()=>[]),r=Hi(t,`label`,3,`Token activity`),i=Hi(t,`capacity`,3,0),a=P(()=>Math.max(0,...n())),o=P(()=>i()>n().length?[...Array(i()-n().length).fill(0),...n()]:n());var s=Ja(),c=z(s),l=B(c),u=V(c,2);Z(u,21,()=>G(o),qr,(e,t)=>{var n=qa();let r;H((e,t)=>{Q(n,`title`,e),r=ui(n,``,r,{height:t})},[()=>G(t).toLocaleString(`en-GB`)+` tokens`,()=>`${100*G(t)/Math.max(1,G(a))}%`]),J(e,n)}),M(u),H((e,t)=>{Y(l,`SCALE // 0 — ${e??``} TOKENS`),Q(u,`aria-label`,t)},[()=>G(a).toLocaleString(`en-GB`),()=>`${r()}. Peak ${G(a).toLocaleString(`en-GB`)} tokens.`]),J(e,s),Ge()}var Xa=q(`

      `),Za=q(`

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

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

       

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

      `),eo=q(`

      CURRENT PROFILE

      MODEL / REASONING LEVEL / SPEED

       

      PROPOSED PROFILE

      MODEL / REASONING LEVEL / SPEED

       

      Applied settings remain after Codexometer closes.

      `,1),to=q(`

      `),no=q(`

       
      `,1),ro=q(`

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

      `),io=q(`

      Session controls temporarily unavailable. Check Codex for current state.

      `),ao=q(`

      Checking session controls…

      `),oo=q(`

      `),so=q(`
      About browser controls

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

      `),co=q(` `,1),lo=q(` `),uo=q(`Grants permission beyond this one command. Check the scope + carefully.`),fo=q(``),po=q(``),mo=q(``),ho=q(``),go=q(`

      Not before this time; when quota and idle allow.

      `),_o=q(`

      `,1),vo=q(`

      The existing trigger remains active until saved or deleted. If + it changes or sends while you edit, saving is blocked.

      `),yo=q(` `,1),bo=q(``),xo=q(``),So=q(``),Co=q(` `,1),wo=q(`
    • `),To=q(`
      View fixed choices

      Type one of these choices exactly. Your answer stays masked.

        `),Eo=q(` `,1),Do=q(`

        `),Oo=q(`

        `,1),ko=q(` `,1),Ao=q(``),jo=q(`

        `,1),Mo=q(`

        `);function No(e,t){We(t,!0);let n=[],r=Hi(t,`observedCommand`,3,``),i=Hi(t,`review`,3,``),a=Hi(t,`suspended`,3,!1),o=Hi(t,`openScheduler`,3,!1),s=Hi(t,`onProtectedChange`,3,e=>{}),c=I(null),l=I(Xt([])),u=I(null),d=I(``),f=I(0),p=I(Xt(Date.now())),m=I(!1),h=I(``),g=I(!1),_=I(!1),v=I(``),y=I(!1),b=I(``),x=I(!1),S=I(``),C=I(`now`),w=I(60),T=I(``),E=I(!1),ee=I(``),D=I(Xt([])),te=P(()=>G(D).find(e=>e.status!==`sent`)),ne=I(void 0);function re(e){if(G(y)){ur().then(()=>G(ne)?.focus());return}if(e.status!==`pending`||G(m)||!G(c)?.id||G(c).kind!==`prompt`||G(c).questions?.length)return;L(y,!0),L(b,e.id,!0),ur().then(()=>G(ne)?.focus()),L(l,[e.text],!0),L(C,e.trigger===`quota`?`quota`:`at`,!0),L(d,``),L(S,``);let t=new Date(e.at);e.trigger!==`quota`&&L(T,new Date(t.getTime()-t.getTimezoneOffset()*6e4).toISOString().slice(0,16),!0)}yn(()=>{o()&&G(te)&&!G(x)&&G(c)?.id&&G(c).kind===`prompt`&&!G(c).questions?.length&&(L(x,!0),re(G(te)))});async function O(){if(!i())try{L(D,await da(`schedules`,{session:t.session},_e.signal),!0),G(y)&&!G(D).some(e=>e.id===G(b)&&e.status===`pending`)&&L(d,``),G(S)&&!G(D).some(e=>e.id===G(S)&&e.status===`pending`)&&(L(d,``),L(S,``))}catch{}}async function ie(e){try{L(D,await da(`schedules`,{session:t.session,cancelId:e},_e.signal),!0),L(y,!1),L(d,``),L(S,``),L(l,[],!0)}catch(e){L(h,e instanceof Error?e.message:`Unable to cancel trigger.`,!0)}}yn(()=>(s()(G(m)||G(l).some(e=>e.length>0)),()=>s()(!1)));let ae=P(()=>$.data?.sessions.find(e=>e.id===t.session)?.status),oe=P(()=>!$.connected||!!$.data?.sessionsError),se=P(()=>i()!==`profile`&&G(c)?.kind===`prompt`&&!G(c).questions?.length&&!!$.data?.profiles?.some(e=>e.session===t.session&&e.pending)),ce=P(()=>!G(oe)&&!G(_)&&!!G(c)?.id&&G(c).kind===`approval`&&G(v)!==G(c).id),le=P(()=>G(ce)?G(c).command:r()),ue=P(()=>!!G(d)&&G(p)G(c)?.questions?.length?G(c).questions:[{text:`Follow-up message`,secret:!1,freeText:!0,options:[]}]),fe=P(()=>G(c)?.kind===`approval`||G(c)?.kind===`profile`?G(u)!==null:G(l).length===G(de).length&&G(l).every((e,t)=>e.trim().length>0&&(G(de)[t].freeText||G(de)[t].options?.includes(e)))),pe=P(()=>G(c)?.kind===`prompt`&&!G(c).questions?.length&&G(C)!==`now`),me=P(()=>G(C)===`delay`?G(p)+G(w)*6e4:Date.parse(G(T))),k=P(()=>{if(!G(pe))return``;if(G(y)&&!G(D).some(e=>e.id===G(b)&&e.status===`pending`))return`Trigger changed, removed or already sent. Go back and reopen it; your draft has not been saved.`;if(!G(l)[0]?.trim())return`Enter a message to schedule.`;if(G(C)===`quota`)return``;if(!Number.isFinite(G(me))||G(me)<=G(p))return`Choose a future date/time or a delay greater than zero.`;let e=new Date(G(p));return e.setFullYear(e.getFullYear()+1),G(me)>=e.getTime()?`Choose a time within one year.`:``});function he(e){return new Date(e).toLocaleString([],{timeZoneName:`short`})}function ge(e){if(e.status!==`pending`)return e.status;if(e.trigger===`at`&&Date.parse(e.at)>G(p))return`Due in `+Math.ceil((Date.parse(e.at)-G(p))/6e4)+` min`;let t=G(oe)?`waiting for a fresh connection`:e.waitingReason||(e.canSend?`ready; awaiting dispatch`:`waiting for quota and an eligible idle session`);return(e.trigger===`at`?`Time reached — `:``)+t}yn(()=>{(G(oe)||a()||G(se)||G(p)>=G(f))&&L(d,``)});let _e=new AbortController;Ui(()=>{let e=e=>{e.ctrlKey&&e.key.toLowerCase()===`s`&&!i()&&G(te)&&(e.preventDefault(),re(G(te)))};window.addEventListener(`keydown`,e);let n,r=setInterval(()=>{L(p,Date.now(),!0)},1e3);async function a(){await O();try{let e=await da(`offer`,{session:t.session,...i()?{review:i()}:{}},_e.signal);if(_e.signal.aborted)return;L(_,!1),G(c)?.id!==e.id&&(L(d,``),L(u,null),L(l,Array(Math.max(1,e.questions?.length||0)).fill(``),!0)),L(c,e,!0),G(g)&&e.id&&e.id!==G(v)&&(L(h,``),L(g,!1))}catch{_e.signal.aborted||(L(c,null),L(_,!0),L(d,``))}finally{_e.signal.aborted||(n=setTimeout(a,2e3))}}return a(),()=>{window.removeEventListener(`keydown`,e),_e.abort(),clearTimeout(n),clearInterval(r)}});async function ve(e){if(!G(c)?.id||G(m)||G(oe)||a()||G(se)||(e?!e.canSend:!G(fe)||G(k)))return;let n=G(c).id;L(m,!0),L(h,``),L(g,!1),L(d,``),L(S,e?.id||``,!0);try{let r=!e&&G(c).kind===`prompt`&&!G(c).questions?.length&&G(C)!==`now`?{trigger:G(C)===`quota`?`quota`:`at`,zone:Intl.DateTimeFormat().resolvedOptions().timeZone,...G(C)===`quota`?{}:{at:(G(C)===`delay`?new Date(Date.now()+G(w)*6e4):new Date(G(T))).toISOString()}}:void 0;L(E,!!r),L(ee,r?r.trigger===`quota`?`After fresh quota becomes available, when the session is idle`:he(r.at)+` — not before this time; when quota and idle allow`:``,!0);let o=await da(`prepare`,{session:t.session,...i()?{review:i()}:{},offer:n,...e?{sendId:e.id}:{},...r?{schedule:r}:{},...r&&G(y)?{editId:G(b)}:{},...G(c).kind===`approval`||G(c).kind===`profile`?{choice:G(u)}:{answers:e?[e.text]:[...G(l)]}},_e.signal);G(c)?.id===n&&!_e.signal.aborted&&!G(oe)&&!a()&&!G(se)&&(L(d,o.confirmation,!0),L(f,Date.parse(o.expires),!0))}catch(e){_e.signal.aborted||L(h,e instanceof Error?e.message:`Unable to prepare action.`,!0)}finally{L(m,!1)}}async function ye(){if(!G(c)?.id||G(m)||a()||G(se)||!G(ue))return;let e=G(c).id,n=G(c).kind,r=G(d);L(d,``),L(m,!0),L(v,e,!0);try{await da(`commit`,{session:t.session,...i()?{review:i()}:{},offer:e,confirmation:r},_e.signal),L(g,!0),L(y,!1),G(E)&&(L(v,``),await O()),L(h,G(E)?`Trigger set. Closing Codexometer cancels it.`:n===`profile`?`Profile decision completed.`:n===`approval`?`Decision sent.`:`Text sent.`,!0)}catch(e){L(h,e instanceof la?e.message:`Outcome uncertain. Check Codex before taking another action; nothing was retried.`,!0)}finally{L(m,!1),L(S,``),await O(),L(l,[],!0),L(u,null)}}var A=co(),be=z(A),j=e=>{var t=Pr();Z(z(t),17,()=>G(D).filter(e=>e.status!==`sent`),e=>e.id,(e,t)=>{var n=$a(),r=R(n),i=B(r,!0),o=V(r,2),s=B(o),l=V(o,2),u=B(l,!0),f=V(l,2),p=B(f,!0),g=V(f,2),_=B(g,!0),v=V(g,2),y=e=>{var t=Xa(),n=B(t,!0);H(()=>Y(n,G(h))),J(e,t)};X(v,e=>{G(h)&&e(y)});var b=V(v,4),x=V(b,2),C=V(x,2),w=e=>{var n=Za(),r=V(z(n),2),i=V(r,2);H(()=>r.disabled=G(m)||G(oe)||!G(t).canSend),K(`click`,r,ye),K(`click`,i,()=>{L(d,``),L(S,``)}),J(e,n)},T=e=>{var n=Qa();H(()=>n.disabled=G(m)||G(oe)||!G(t).canSend||a()),K(`click`,n,()=>ve(G(t))),J(e,n)};X(C,e=>{G(ue)&&G(S)===G(t).id?e(w):e(T,-1)}),M(n),H((e,n)=>{Y(i,G(t).status===`uncertain`?`CHECK TRIGGER — outcome uncertain; not retried`:`SCHEDULED FOLLOW-UP`),Y(s,`TARGET // ${G(t).session??``}`),Y(u,e),Y(p,n),Y(_,G(t).text),b.disabled=G(m)||!G(c)?.id||G(c).kind!==`prompt`||!!G(c).questions?.length||G(t).status!==`pending`,x.disabled=G(m)||G(t).status===`sending`},[()=>G(t).trigger===`quota`?`After confirmed quota recovery, when idle`:`Not before ${he(G(t).at)}`,()=>ge(G(t))]),K(`click`,b,()=>re(G(t))),K(`click`,x,()=>ie(G(t).id)),J(e,n)}),J(e,t)},xe=P(()=>!i()&&G(c)?.kind!==`approval`&&!G(c)?.questions?.length&&![`APPROVAL NEEDED`,`INPUT NEEDED`].includes(G(ae)||``));X(be,e=>{G(xe)&&e(j)});var Se=V(be,2),we=e=>{var r=Mo(),a=R(r),o=B(a,!0),s=V(a,2),b=e=>{var t=eo(),n=z(t),r=B(n),i=V(n,6),a=B(i,!0),o=B(V(i,6),!0);Ce(2),H(()=>{Y(r,`Your ${G(c).profile.threshold??``}% quota threshold has been reached. Review + the profile for subsequent turns.`),Y(a,G(c).profile.current),Y(o,G(c).profile.proposed)}),J(e,t)};X(s,e=>{G(c)?.profile&&!G(oe)&&!G(_)&&e(b)});var x=V(s,2),S=e=>{var t=to();let n;var r=B(t,!0);H(()=>{n=ci(t,1,`svelte-1oupzfc`,null,n,{notice:!G(g),sent:G(g)}),Y(r,G(h))}),J(e,t)};X(x,e=>{G(h)&&e(S)});var D=V(x,2),te=e=>{var t=no(),n=z(t),r=B(n,!0),i=B(V(n,2),!0);H(()=>{Y(r,G(ce)?`EXACT COMMAND`:`LAST OBSERVED COMMAND`),Y(i,G(le))}),J(e,t)},re=e=>{J(e,ro())};X(D,e=>{G(le)?e(te):i()!==`profile`&&G(ae)===`APPROVAL NEEDED`&&!G(g)&&e(re,1)});var O=V(D,2),ie=e=>{J(e,io())},se=e=>{J(e,ao())},pe=e=>{var t=co(),n=z(t),r=e=>{var t=oo(),n=B(t,!0);H(e=>Y(n,e),[()=>G(ae)===`WORKING`?`Codex is working — nothing to respond to.`:[`APPROVAL NEEDED`,`INPUT NEEDED`].includes(G(ae)||``)?`Respond in Codex for this request.`:`Nothing needs a response right now.`]),J(e,t)};X(n,e=>{(G(ae)===`WORKING`||!G(g)&&!G(m))&&e(r)});var i=V(n,2),a=e=>{J(e,so())},o=P(()=>[`APPROVAL NEEDED`,`INPUT NEEDED`].includes(G(ae)||``)&&!G(g));X(i,e=>{G(o)&&e(a)}),J(e,t)},ge=e=>{var r=jo(),a=z(r),o=B(a),s=V(a,2),h=R(s),g=B(h,!0),_=V(h,2),v=e=>{var r=Pr();Z(z(r),17,()=>G(c).choices||[],qr,(e,r,a)=>{var o=fo(),s=R(o);xi(s),s.value=s.__value=a;var c=V(s),l=V(c),d=e=>{var t=lo(),n=B(t,!0);H(()=>Y(n,G(r).detail)),J(e,t)};X(l,e=>{G(r).detail&&e(d)});var f=V(l,2),p=e=>{J(e,uo())};X(f,e=>{G(r).persistent&&e(p)}),M(o),H(()=>{Q(s,`name`,`decision-`+t.session+`-`+i()),Y(c,` ${G(r).label??``} `)}),Di(n,[],s,()=>G(u),e=>L(u,e)),J(e,o)}),J(e,r)},b=e=>{var t=co(),n=z(t),r=e=>{var t=yo(),n=z(t),r=V(R(n)),i=R(r),a=e=>{var t=po();t.value=t.__value=`now`,J(e,t)};X(i,e=>{G(y)||e(a)});var o=V(i);o.value=o.__value=`quota`;var s=V(o);s.value=s.__value=`delay`;var c=V(s);c.value=c.__value=`at`,M(r),mi(r),M(n);var l=V(n,2),u=e=>{var t=mo(),n=V(R(t));xi(n),M(t),Ti(n,()=>G(w),e=>L(w,e)),J(e,t)};X(l,e=>{G(C)===`delay`&&e(u)});var d=V(l,2),f=e=>{var t=ho(),n=V(R(t));xi(n),M(t),Ti(n,()=>G(T),e=>L(T,e)),J(e,t)};X(d,e=>{G(C)===`at`&&e(f)});var p=V(d,2),m=e=>{var t=_o(),n=z(t),r=B(n),i=V(n,2),a=e=>{J(e,go())};X(i,e=>{G(C)!==`quota`&&e(a)});var o=V(i,2);o.textContent=`Timezone: ${Intl.DateTimeFormat().resolvedOptions().timeZone??``}. + One pending request per session; saving replaces it. Memory + only; Codexometer must stay running. Uses session model, + reasoning and speed.`,H(e=>Y(r,`Will send: ${e??``}`),[()=>G(C)===`quota`?`when quota is available and this session is idle`:Number.isFinite(G(me))?he(G(me)):`choose a date and time`]),J(e,t)};X(p,e=>{G(C)!==`now`&&e(m)});var h=V(p,2),g=e=>{J(e,vo())};X(h,e=>{G(y)&&e(g)}),hi(r,()=>G(C),e=>L(C,e)),J(e,t)};X(n,e=>{G(c).questions?.length||e(r)}),Z(V(n,2),17,()=>G(de),qr,(e,t,n)=>{var r=Eo(),i=z(r),a=R(i),o=V(a),s=e=>{var t=bo();xi(t),Ti(t,()=>G(l)[n],e=>G(l)[n]=e),J(e,t)},c=e=>{var r=So(),i=R(r);i.value=i.__value=``,Z(V(i),17,()=>G(t).options||[],qr,(e,t)=>{var n=xo(),r=B(n,!0),i={};H(()=>{Y(r,G(t)),i!==(i=G(t))&&(n.value=(n.__value=i)??``)}),J(e,n)}),M(r),mi(r),hi(r,()=>G(l)[n],e=>G(l)[n]=e),J(e,r)},u=e=>{var r=Co(),i=z(r);nt(i),Pi(i,e=>L(ne,e),()=>G(ne));var a=V(i,2),o=e=>{var n=oo(),r=B(n);H(e=>Y(r,`Suggested answers: ${e??``}`),[()=>G(t).options.join(` · `)]),J(e,n)};X(a,e=>{G(t).options?.length&&e(o)}),Ti(i,()=>G(l)[n],e=>G(l)[n]=e),J(e,r)};X(o,e=>{G(t).secret?e(s):G(t).freeText?e(u,-1):e(c,1)}),M(i);var d=V(i,2),f=e=>{var n=To(),r=V(R(n),4);Z(r,21,()=>G(t).options||[],qr,(e,t)=>{var n=wo(),r=B(n,!0);H(()=>Y(r,G(t))),J(e,n)}),M(r),M(n),J(e,n)};X(d,e=>{G(t).secret&&!G(t).freeText&&e(f)}),H(()=>Y(a,`${G(t).text??``} `)),J(e,r)}),J(e,t)};X(_,e=>{G(c).kind===`approval`||G(c).kind===`profile`?e(v):e(b,-1)}),M(s);var x=V(s,2),S=e=>{var t=Oo(),n=z(t),r=e=>{var t=Do(),n=B(t);H(()=>Y(n,`TRIGGER // ${G(ee)??``}`)),J(e,t)};X(n,e=>{G(E)&&e(r)});var i=V(n,2),a=B(i),o=V(i,2),s=B(o),l=V(o,2);H(e=>{Y(a,`Check the target and ${G(c).kind===`profile`?`profile above`:G(c).kind===`approval`?`exact command and permission scope`:`message above`}. ${G(E)?`This schedules a follow-up that will send automatically when eligible`:`This will send to Codex`}; it may start work using your quota. + Confirmation expires in ${e??``}s.`),o.disabled=G(m)||G(oe),Y(s,`CONFIRM ${(G(c).kind===`approval`||G(c).kind===`profile`?G(c).choices?.[G(u)]?.label:G(E)?G(y)?`SAVE CHANGES`:`SCHEDULE`:`SEND`)??``}`)},[()=>Math.max(0,Math.ceil((G(f)-G(p))/1e3))]),K(`click`,o,ye),K(`click`,l,()=>{L(d,``)}),J(e,t)},D=e=>{var t=ko(),n=z(t),r=e=>{var t=Xa(),n=B(t,!0);H(()=>Y(n,G(k))),J(e,t)};X(n,e=>{G(k)&&e(r)});var i=V(n,2),a=B(i,!0);H(()=>{i.disabled=G(m)||G(oe)||!G(fe)||!!G(k),Y(a,G(m)?`SENDING…`:G(y)?`REVIEW CHANGES`:`REVIEW BEFORE SENDING`)}),K(`click`,i,()=>ve()),J(e,t)};X(x,e=>{G(ue)?e(S):e(D,-1)});var te=V(x,2),re=e=>{var t=Ao();K(`click`,t,()=>{L(y,!1),L(d,``),L(l,[],!0)}),J(e,t)};X(te,e=>{G(y)&&e(re)}),H(()=>{Y(o,`TARGET // ${G(c).thread??``} // ${(G(c).directory||`Directory unavailable`)??``}`),s.disabled=G(m)||G(ue)||G(oe),Y(g,G(c).kind===`approval`||G(c).kind===`profile`?`Choose a decision`:G(y)?`EDIT SCHEDULED FOLLOW-UP`:`Reply to this session`)}),J(e,r)};X(O,e=>{G(oe)||G(_)?e(ie):G(c)?G(c).id?G(v)!==G(c).id&&e(ge,3):e(pe,2):e(se,1)}),M(r),H(()=>Y(o,i()===`profile`?`QUOTA THRESHOLD`:`SESSION CONTROL // EXPERIMENTAL`)),J(e,r)};X(Se,e=>{!G(se)&&(!G(te)||G(y)||i()||G(c)?.kind!==`prompt`||G(c).questions?.length)&&e(we)}),J(e,A),Ge()}Cr([`click`]);var Po=q(`
        `);function Fo(e,t){We(t,!0);let n=Hi(t,`active`,3,!1),r=P(()=>t.session.text||t.session.contextKind===`LAST ACTIVITY`&&t.session.workingCommand||``),i=P(()=>[`LAST REPLY`,`LAST ACTIVITY`].includes(t.session.contextKind)&&!!G(r).trim()),a=I(!1),o=I(``),s=I(!1);yn(()=>{if(!G(s))return;let e=setTimeout(()=>{L(s,!1)},150);return()=>clearTimeout(e)}),yn(()=>{G(r),t.session.status,L(o,``)});async function c(){if(!G(i)||G(a))return;let e=G(r);L(a,!0),L(s,!0),L(o,``);try{await navigator.clipboard.writeText(e),G(r)===e&&L(o,`Copied.`)}catch{G(r)===e&&L(o,`Clipboard unavailable. Select the visible text and copy it manually.`)}finally{L(a,!1)}}function l(e){!n()||!G(i)||e.defaultPrevented||e.repeat||e.ctrlKey||e.metaKey||e.altKey||e.key.toLowerCase()!==`c`||e.target instanceof Element&&e.target.closest(`input, textarea, select, [contenteditable]`)||(e.preventDefault(),c())}var u=Pr();Sr(`keydown`,$t,l);var d=z(u),f=e=>{var t=Po(),n=R(t),r=B(n,!0),i=V(n,2);let l;M(t),H(()=>{Y(r,G(o)),i.disabled=G(a),l=ci(i,1,`svelte-543j00`,null,l,{flashed:G(s)})}),K(`click`,i,c),J(e,t)};X(d,e=>{G(i)&&e(f)}),J(e,u),Ge()}Cr([`click`]);var Io=q(`

         
        `,1),Lo=q(`

        LATEST GUIDANCE

         
        `,1),Ro=q(`

        CONTEXT

         

        `,1),zo=q(`

        `),Bo=q(`

        `),Vo=q(`
         
        `),Ho=q(`

         
        `,1),Uo=q(`
         
        `),Wo=q(`

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

        `),Go=q(`

        `,1),Ko=q(`

        `),qo=q(` `,1),Jo=q(`
        `),Yo=q(`

        Session connection or refresh unavailable. Context and telemetry may be + stale.

        `),Xo=q(`

        Some quota profile checks are unavailable. Only sessions with freshly + verified quota and settings can be updated; previous outcome notices remain + visible.

        `),Zo=q(` `),Qo=q(` `),$o=q(` `),es=q(``),ts=q(`

        Read only — reply or approve in Codex.

        `),ns=q(`

        `),rs=q(`QUOTA THRESHOLD // REVIEW PROFILE ↗`),is=q(`
        `),as=q(`
        `),os=q(`

        This session is no longer in the current observation. Return to sessions.

        `),ss=q(`

        `),cs=q(``),ls=q(`

        `),us=q(` `),ds=q(`

        `),fs=q(`QUOTA THRESHOLD // REVIEW PROFILE ↗`),ps=q(` `,1),ms=q(`

        `),hs=q(`

        TOKEN ACTIVITY // 30 SECOND SAMPLES

        `),gs=q(`

        TOKENS

        FULL DETAIL →
        `),_s=q(`

        No locally observed sessions yet. Keep Codex running alongside + Codexometer.

        `),vs=q(`

        ↑ ↓ SELECT SESSION // ← LESS DETAIL // → MORE DETAIL // ESC BACK

        `,1),ys=q(`

        SESSION TOTALS

        `,1);function bs(e,t){We(t,!0);let n=(e,t=f,n,r)=>{let i=mt(()=>g(n?.(),!0)),a=mt(()=>g(r?.(),!1));var o=qo(),s=z(o),c=e=>{var n=Io(),r=z(n),i=B(r,!0),a=B(V(r,2),!0);H(()=>{Y(i,t().contextKind===`LAST REPLY`&&!t().streaming?`TASK`:`CURRENT TASK`),Y(a,t().currentTask)}),J(e,n)};X(s,e=>{t().currentTask&&e(c)});var l=V(s,2),u=e=>{var n=Lo(),r=B(V(z(n),2),!0);H(()=>Y(r,t().latestGuidance)),J(e,n)};X(l,e=>{t().latestGuidance&&e(u)});var d=V(l,2),p=e=>{var n=Ro(),r=B(V(z(n),2),!0);Ce(2),H(()=>Y(r,t().approvalContext)),J(e,n)};X(d,e=>{G(a)&&t().contextKind===`APPROVAL REQUEST`&&t().approvalContext&&e(p)});var m=V(d,2),_=e=>{var n=zo();let r;var i=B(n,!0);H(e=>{r=ci(n,1,`attention-note`,null,r,{inferred:t().status===`CHECK SESSION`}),Y(i,e)},[()=>C(t())]),J(e,n)},v=P(()=>C(t())&&(!G(a)||G(h)||t().status===`CHECK SESSION`));X(m,e=>{G(v)&&e(_)});var y=V(m,2),b=e=>{var n=Bo(),r=B(n,!0);H(e=>Y(r,e),[()=>w(t())]),J(e,n)};X(y,e=>{G(i)&&e(b)});var x=V(y,2),S=e=>{var n=Vo(),r=B(n,!0);H(()=>Y(r,t().text||`No session context available.`)),J(e,n)};X(x,e=>{(t().text||!t().workingCommand)&&e(S)});var T=V(x,2),E=e=>{var n=Ho(),r=V(z(n),2),i=R(r),a=V(i),o=e=>{var n=Nr();H(()=>Y(n,`// +${(t().runningCommands||0)-+(t().commandStatus===`running`)} RUNNING`)),J(e,n)};X(a,e=>{!G(h)&&(t().runningCommands||0)>+(t().commandStatus===`running`)&&e(o)});var s=V(a,2),c=e=>{J(e,Nr(`+`))};X(s,e=>{t().runningLimited&&e(c)}),M(r);var l=B(V(r,2),!0);H(e=>{Y(i,`COMMAND // ${e??``} `),Y(l,t().workingCommand)},[()=>G(h)?`UNKNOWN`:(t().commandStatus||`unknown`).toUpperCase()]),J(e,n)};X(T,e=>{t().workingCommand&&e(E)});var ee=V(T,2),D=e=>{var n=Go(),r=V(z(n),2),i=B(r,!0),a=V(r,2),o=e=>{var n=Uo(),r=B(n,!0);H(()=>Y(r,t().command)),J(e,n)},s=e=>{J(e,Wo())};X(a,e=>{t().command?e(o):e(s,-1)}),H(()=>Y(i,G(h)||t().status!==`APPROVAL NEEDED`?`LAST OBSERVED COMMAND`:$.data?.control?`COMMAND REQUEST`:`COMMAND TO APPROVE IN CODEX`)),J(e,n)};X(ee,e=>{(!G(a)||!$.data?.control)&&(t().command||t().status===`APPROVAL NEEDED`)&&e(D)});var te=V(ee,2),ne=e=>{var n=Ko(),r=B(n);H(()=>Y(r,`CONTEXT SOURCE // ${(t().source||`LOCAL`)??``} // OBSERVATION`)),J(e,n)};X(te,e=>{G(a)||e(ne)}),J(e,o)},r=Hi(t,`params`,19,()=>({})),i=P(()=>$.data?.sessions||[]),a=P(()=>$.data?.control&&$.data.profiles||[]),o=P(()=>($.data?.triggers||[]).filter(e=>G(i).some(t=>t.id===e.session)));function s(e,t){if(r().id&&r().id!==t&&G(l)){e.preventDefault();return}b(t)}function c(e){return[`TURN COMPLETE`,`IDLE`,`ACTIVE`].includes(e.status)&&G(o).some(t=>t.session===e.id)?`TRIGGER SET`:e.status}let l=I(!1);function u(e,t){if(r().id&&r().id!==t&&G(l)){e.preventDefault();return}b(t)}yn(()=>{let e=r().id;e&&pr(()=>{ea.selected=e,aa(e,2)})});let d=P(()=>G(i).find(e=>e.id===r().id)),p=P(()=>new URLSearchParams(Ki.querystring).get(`review`)===`profile`&&G(a).some(e=>e.session===r().id&&e.pending)),m=P(()=>G(i).some(e=>e.id===ea.selected)?ea.selected:G(i)[0]?.id),h=P(()=>!$.connected||!!$.data?.sessionsError),v=P(()=>[[`OBSERVED TOKENS`,oa(G(i).reduce((e,t)=>e+t.tokens,0))],[`LISTED SESSIONS`,oa(G(i).length)],...[[`WORKING`,`WORKING`],[`AWAITING APPROVAL`,`APPROVAL NEEDED`],[`AWAITING INPUT`,`INPUT NEEDED`],[`CHECK · INFERRED`,`CHECK SESSION`]].map(([e,t])=>[e,G(h)?`—`:oa(G(i).filter(e=>e.status===t).length)])]),y=P(()=>G(i).filter(e=>[`INPUT NEEDED`,`APPROVAL NEEDED`,`CHECK SESSION`].includes(e.status)));function b(e){ea.selected=e,ur().then(()=>document.querySelector(`.session-row.selected`)?.scrollIntoView({block:`nearest`}))}function x(e,t){if(b(e),r().id){t<0&&(aa(e,2),location.hash=`/sessions`);return}let n=ra(e)+t;n>2?location.hash=`/sessions/`+encodeURIComponent(e):aa(e,n)}function S(e){let t=e.target;if(!(e.altKey||e.ctrlKey||e.metaKey||e.shiftKey||t.closest(`input, textarea, select, [contenteditable="true"]`))){if(e.key===`Escape`&&r().id)e.preventDefault(),x(r().id,-1);else if([`ArrowLeft`,`ArrowRight`].includes(e.key)&&(r().id||G(m)))e.preventDefault(),x(r().id||G(m),e.key===`ArrowLeft`?-1:1);else if(!r().id&&[`ArrowUp`,`ArrowDown`].includes(e.key)&&G(i).length){e.preventDefault();let t=G(i).findIndex(e=>e.id===G(m));b(G(i)[Math.max(0,Math.min(G(i).length-1,t+(e.key===`ArrowUp`?-1:1)))].id)}}}function C(e){return G(h)?`Last observation only — refresh unavailable. Check Codex for current state.`:e.status===`CHECK SESSION`?`INFERRED INACTIVITY — a quiet session, not a confirmed input or approval request. Local tools may still be running.`:$.data?.control&&[`APPROVAL NEEDED`,`INPUT NEEDED`].includes(e.status)?`Open full detail for supported live controls; otherwise reply in Codex.`:e.status===`APPROVAL NEEDED`?`OBSERVED APPROVAL SIGNAL — approve or decline in Codex.`:e.status===`INPUT NEEDED`?`OBSERVED INPUT SIGNAL — reply in Codex.`:e.status===`TURN COMPLETE`?`OBSERVED TURN COMPLETION — informational, not an approval request.`:``}function w(e){return e.streaming&&e.contextKind===`LAST REPLY`?`REPLY // STREAMING`:e.contextKind||`LAST ACTIVITY`}var T=ys();Sr(`keydown`,$t,S);var E=z(T),ee=B(V(R(E),2));M(E);var D=V(E,2);let te;Z(D,21,()=>G(v),qr,(e,t)=>{var n=P(()=>_(G(t),2));let r=()=>G(n)[0],i=()=>G(n)[1];var a=Jo(),o=R(a),s=B(o,!0),c=B(V(o,2),!0);M(a),H(()=>{Y(s,r()),Y(c,i())}),J(e,a)}),M(D);var ne=V(D,2),re=B(ne),O=V(ne,2),ie=e=>{J(e,Yo())};X(O,e=>{G(h)&&e(ie)});var ae=V(O,2),oe=e=>{J(e,Xo())};X(ae,e=>{$.data?.profileError&&e(oe)});var se=V(ae,2),ce=e=>{var t=es(),n=R(t);Z(n,17,()=>G(y),qr,(e,t)=>{var n=Zo();let r;var i=B(n);H((e,a)=>{r=ci(n,1,`button`,null,r,{approval:G(t).status===`APPROVAL NEEDED`}),Q(n,`href`,e),Y(i,`${G(t).status??``} + ${a??``} // ${(G(t).name||G(t).directory||G(t).id)??``}`)},[()=>`#/sessions/`+encodeURIComponent(G(t).id),()=>Array.from(G(t).id).slice(-5).join(``).toUpperCase()]),K(`click`,n,()=>b(G(t).id)),J(e,n)});var c=V(n,2);Z(c,17,()=>G(a).filter(e=>e.pending&&G(i).some(t=>t.id===e.session)),qr,(e,t)=>{var n=Qo(),a=B(n);H((e,i,o)=>{Q(n,`title`,r().id&&r().id!==G(t).session&&G(l)?`Finish sending or clear your current draft before switching sessions.`:`Review this session’s quota threshold`),Q(n,`href`,e),Y(a,`QUOTA THRESHOLD ${i??``} // ${o??``}`)},[()=>`#/sessions/`+encodeURIComponent(G(t).session)+`?review=profile`,()=>Array.from(G(t).session).slice(-5).join(``).toUpperCase(),()=>G(i).find(e=>e.id===G(t).session)?.name||G(i).find(e=>e.id===G(t).session)?.directory||G(t).session]),K(`click`,n,e=>u(e,G(t).session)),J(e,n)}),Z(V(c,2),17,()=>G(o),qr,(e,t)=>{var n=$o(),r=B(n);H((e,t,i)=>{Q(n,`href`,e),Y(r,`TRIGGER SET ${t??``} // ${i??``}`)},[()=>`#/sessions/`+encodeURIComponent(G(t).session),()=>Array.from(G(t).session).slice(-5).join(``).toUpperCase(),()=>G(i).find(e=>e.id===G(t).session)?.name||G(i).find(e=>e.id===G(t).session)?.directory]),K(`click`,n,e=>s(e,G(t).session)),J(e,n)}),M(t),J(e,t)},le=P(()=>(G(y).length||G(a).some(e=>e.pending)||G(o).length)&&!G(h));X(se,e=>{G(le)&&e(ce)});var ue=V(se,2),de=e=>{var t=Pr(),i=z(t),o=e=>{var t=as(),i=R(t),o=R(i),s=R(o);let u;var f=V(s);M(o);var m=V(o,2);M(i);var g=V(i,2),_=e=>{var t=Ko(),n=B(t,!0);H(()=>Y(n,G(d).directory)),J(e,t)};X(g,e=>{G(d).name&&G(d).directory&&e(_)});var v=V(g,2),y=B(v),x=V(v,2);let S;var C=R(x),w=R(C);n(w,()=>G(d),()=>!0,()=>!0),M(C);var T=V(C,2),E=e=>{var t=Pr();Kr(z(t),()=>G(d).id,e=>{{let t=P(()=>new URLSearchParams(Ki.querystring).get(`schedule`)===`edit`);No(e,{get session(){return G(d).id},get openScheduler(){return G(t)},get observedCommand(){return G(d).command},get suspended(){return G(p)},onProtectedChange:e=>{L(l,e,!0)}})}}),J(e,t)},ee=e=>{J(e,ts())};X(T,e=>{$.data?.control?e(E):e(ee,-1)}),M(x);var D=V(x,2);Z(D,17,()=>G(a).filter(e=>e.session===G(d).id),e=>e.session,(e,t)=>{var n=is(),r=R(n),i=e=>{var n=ns(),r=B(n,!0);H(()=>Y(r,G(t).notice)),J(e,n)};X(r,e=>{G(t).notice&&e(i)});var a=V(r,2),o=e=>{No(e,{get session(){return G(d).id},review:`profile`})},s=e=>{var t=rs();H(e=>Q(t,`href`,e),[()=>`#/sessions/`+encodeURIComponent(G(d).id)+`?review=profile`]),J(e,t)};X(a,e=>{G(t).pending&&G(p)?e(o):G(t).pending&&e(s,1)}),M(n),H(()=>Q(n,`id`,`quota-profile-`+G(d).id)),J(e,n)});var te=V(D,2),ne=e=>{var t=Pr();Kr(z(t),()=>G(d).id,e=>{Fo(e,{get session(){return G(d)},active:!0})}),J(e,t)};X(te,e=>{G(p)||e(ne)}),M(t),H((e,t)=>{u=ci(s,1,`lamp lit`,null,u,{working:G(d).status===`WORKING`&&!G(h)}),Y(f,`${e??``} // ${(G(d).name||G(d).directory)??``}`),Y(y,`${t??``} TOKENS // ${G(d).id??``} // CONTEXT SOURCE // ${(G(d).source||`LOCAL`)??``}`),S=ui(x,``,S,{display:G(p)?`none`:void 0})},[()=>G(h)?`STALE`:G(p)?`QUOTA THRESHOLD`:c(G(d)),()=>oa(G(d).tokens)]),K(`click`,m,()=>{b(r().id),aa(r().id,2)}),J(e,t)},s=e=>{J(e,os())};X(i,e=>{G(d)?e(o):e(s,-1)}),J(e,t)},fe=e=>{var t=vs(),r=z(t),o=V(R(r),2),s=R(o),l=V(s,2);M(o),M(r);var d=V(r,2);Z(d,17,()=>G(i),e=>e.id,(e,t)=>{let r=P(()=>ra(G(t).id));var i=gs();let o;var s=R(i),l=R(s),d=e=>{var n=ss(),r=R(n),i=B(r);M(n),H(e=>{Q(r,`aria-pressed`,G(m)===G(t).id),Y(i,`${e??``} // ${G(t).name??``}`)},[()=>Array.from(G(t).id).slice(-5).join(``).toUpperCase()]),K(`click`,r,()=>b(G(t).id)),J(e,n)};X(l,e=>{G(t).name&&e(d)});var f=V(l,2),p=R(f),g=R(p);let _;var v=V(g,1,!0);M(p),M(f);var y=V(f,2),S=e=>{var n=cs(),r=B(n,!0);H(()=>{Q(n,`aria-pressed`,G(m)===G(t).id),Y(r,G(t).directory||G(t).id)}),K(`click`,n,()=>b(G(t).id)),J(e,n)};X(y,e=>{G(t).name||e(S)});var C=V(y,2),T=R(C);Ce(),M(C);var E=V(C,2),ee=e=>{var n=Ko(),r=B(n,!0);H(()=>Y(r,G(t).directory)),J(e,n)};X(E,e=>{G(t).name&&G(t).directory&&e(ee)});var D=V(E,2),te=B(D),ne=V(D,2),re=B(ne),O=V(ne,2),ie=R(O),ae=V(ie,2),oe=B(ae,!0),se=V(ae,2);M(O);var ce=V(O,2),le=V(ce,2),ue=e=>{var n=ls(),r=B(n,!0);H(()=>Y(r,G(t).status===`CHECK SESSION`?`INFERRED INACTIVITY`:$.data?.control?`OPEN FULL DETAIL OR REPLY IN CODEX`:G(t).status===`APPROVAL NEEDED`?`APPROVE OR DECLINE IN CODEX`:`REPLY IN CODEX`)),J(e,n)},de=P(()=>!G(h)&&[`APPROVAL NEEDED`,`INPUT NEEDED`,`CHECK SESSION`].includes(G(t).status));X(le,e=>{G(de)&&e(ue)}),M(s);var fe=V(s,2),pe=e=>{var r=ms(),i=R(r),o=R(i),s=B(o,!0),c=V(o,2),l=e=>{var n=us(),r=B(n,!0);H(e=>{Q(n,`href`,e),Y(r,$.data?.control?`REVIEW IN FULL DETAIL ↗`:`APPROVAL IN CODEX ↗`)},[()=>`#/sessions/`+encodeURIComponent(G(t).id)]),J(e,n)};X(c,e=>{!G(h)&&G(t).status===`APPROVAL NEEDED`&&e(l)}),M(i);var d=V(i,2);n(d,()=>G(t),()=>!1);var f=V(d,2);Z(f,17,()=>G(a).filter(e=>e.session===G(t).id),qr,(e,n)=>{var r=ps(),i=z(r),a=e=>{var t=ds(),r=B(t,!0);H(()=>Y(r,G(n).notice)),J(e,t)};X(i,e=>{G(n).notice&&e(a)});var o=V(i,2),s=e=>{var n=fs();H(e=>Q(n,`href`,e),[()=>`#/sessions/`+encodeURIComponent(G(t).id)+`?review=profile`]),K(`click`,n,e=>u(e,G(t).id)),J(e,n)};X(o,e=>{G(n).pending&&e(s)}),J(e,r)});var p=V(f,2);{let e=P(()=>G(m)===G(t).id);Fo(p,{get session(){return G(t)},get active(){return G(e)}})}M(r),H(e=>Y(s,e),[()=>w(G(t))]),J(e,r)};X(fe,e=>{G(r)>0&&e(pe)});var me=V(fe,2),k=e=>{var n=hs(),r=V(R(n),2);{let e=P(()=>(G(t).samples||[]).map(e=>e.tokens));Ya(r,{get values(){return G(e)},capacity:120})}var i=B(V(r,2));M(n),H(()=>Y(i,`LAST ${(G(t).samples?.length||0)??``} SAMPLES // AUTO SCALE`)),J(e,n)};X(me,e=>{G(r)<2&&e(k)}),M(i),H((e,n,a,s,c)=>{o=ci(i,1,`session-row`,null,o,{selected:G(m)===G(t).id,wide:G(r)===2,split:G(r)===1}),Q(i,`aria-label`,`Session `+(G(t).name||G(t).directory||G(t).id)),Q(p,`href`,e),_=ci(g,1,`lamp lit`,null,_,{working:G(t).status===`WORKING`&&!G(h)}),Y(v,n),Y(T,`${a??``} `),Y(te,`${G(t).agents??``} LINKED AGENTS`),Y(re,`ACTIVE // ${s??``}`),ie.disabled=G(r)===0,Q(ae,`aria-expanded`,G(r)>0),Y(oe,G(r)?`HIDE DETAIL`:`SHOW DETAIL`),Q(ce,`href`,c)},[()=>`#/sessions/`+encodeURIComponent(G(t).id),()=>G(h)?`STALE`:c(G(t)),()=>oa(G(t).tokens),()=>sa(G(t).activity),()=>`#/sessions/`+encodeURIComponent(G(t).id)]),K(`click`,p,()=>b(G(t).id)),K(`click`,ie,()=>x(G(t).id,-1)),K(`click`,ae,()=>{b(G(t).id),aa(G(t).id,+!G(r))}),K(`click`,se,()=>x(G(t).id,1)),K(`click`,ce,()=>b(G(t).id)),J(e,i)});var f=V(d,2),p=e=>{J(e,_s())};X(f,e=>{G(i).length||e(p)}),K(`click`,s,()=>ia(1)),K(`click`,l,()=>ia(0)),J(e,t)};X(ue,e=>{r().id?e(de):e(fe,-1)}),H(e=>{Y(ee,`OBSERVED ${e??``}`),te=ci(D,1,`session-totals`,null,te,{stale:G(h)}),Y(re,`${G(h)?`LAST KNOWN TOTALS — live state counts unavailable. `:``}Tokens + observed since this server started for currently listed sessions; linked + agents are already included. Totals can decrease when a session leaves the + list. History samples every 30 seconds. Not account-wide totals.`)},[()=>sa($.data?.sessionsAt)]),J(e,T),Ge()}Cr([`click`]);var xs=q(`

        `),Ss=q(`

        Relative usage/credits are not tokens or quota percentages. Groupings + describe the same usage, not additional consumption.

        `,1),Cs=q(`// APPROXIMATE`),ws=q(`

        Partial accounting: totals may still change.

        `),Ts=q(`

        Percentage of this period's historical allowance, not today's limit. May + exceed 100%. This is not a manual/automatic reset log.

        `,1),Es=q(`

        `,1),Ds=q(`

        This optional account report is unavailable. Token history remains + available; missing data does not mean zero usage.

        `),Os=q(`
        `),ks=q(`
        `),As=q(`

        No usage reported for this day.

        `),js=q(`

        Selected breakdown unavailable; missing does not mean zero.

        `),Ms=q(`

        `,1);function Ns(e,t){We(t,!0);let n=I(`surface`),r=I(0),i=P(()=>t.reports?.daily),a=P(()=>t.reports?.plan),o=P(()=>t.mode===`breakdown`?G(i)?.days.length||0:G(a)?.periods.length||0),s=P(()=>Math.min(G(r),Math.max(0,G(o)-1))),c=P(()=>G(i)?.days[(G(i)?.days.length||0)-1-G(s)]),l=P(()=>G(a)?.periods[G(s)]),u=P(()=>t.mode===`breakdown`?G(i)?.units||``:`%`),d=P(()=>{let e=Object.create(null);if(t.mode===`breakdown`)e=G(c)?.groups[G(n)]||{};else{let t=G(n)===`feature`?`thread_source`:G(n)===`task start`?`turn_trigger`:G(n);for(let n of G(l)?.breakdowns||[])if(n.dimension===t)for(let t of n.rows)e[t.key]=(e[t.key]||0)+t.basis_points/100}return Object.entries(e).sort((e,t)=>t[1]-e[1]||e[0].localeCompare(t[0]))}),f=P(()=>t.mode===`breakdown`?G(c)?.total:G(l)?.used_basis_points==null?null:G(l).used_basis_points/100),p=P(()=>Math.max(1,G(f)||0,...G(d).map(([,e])=>e))),m=e=>e==null?`UNKNOWN`:e.toLocaleString(`en-GB`,{maximumFractionDigits:2});var h=Ms(),g=z(h),v=R(g),y=V(R(v)),b=R(y);b.value=b.__value=`surface`;var x=V(b);x.value=x.__value=`model`;var S=V(x);S.value=S.__value=`feature`;var C=V(S);C.value=C.__value=`task start`,M(y),mi(y),M(v);var w=V(v,2),T=V(w,2),E=B(V(T,2),!0);M(g);var ee=V(g,2),D=R(ee),te=B(D,!0),ne=V(D,2),re=B(ne),O=V(ne,2),ie=e=>{var t=Ss(),n=z(t),r=B(n),a=V(n,2),o=e=>{var t=xs(),n=B(t);H(()=>Y(n,`Data as of ${G(i).dataAsOf??``}`)),J(e,t)};X(a,e=>{G(i).dataAsOf&&e(o)});var s=B(V(a,2));Ce(2),H((e,t)=>{Y(r,`Latest fetch ${e??``} // queried ${G(i).from??``} → ${G(i).through??``}. + Reports are fetched from OpenAI and are not saved between runs.`),Y(s,`${(G(c)?.date||`No daily buckets reported`)??``} // ${t??``} + ${G(u)??``}`)},[()=>sa(G(i).fetchedAt),()=>m(G(f))]),J(e,t)},ae=e=>{var t=Es(),n=z(t),r=R(n);let i;var o=B(r,!0),s=V(r),c=e=>{J(e,Cs())};X(s,e=>{G(a).approximate&&e(c)});var u=V(s);M(n);var d=V(n,2),p=R(d),h=V(p),g=e=>{var t=Nr();H(()=>Y(t,`// Boundary tolerance ${G(a).boundary_tolerance_seconds??``}s`)),J(e,t)};X(h,e=>{G(a).boundary_tolerance_seconds!=null&&e(g)}),M(d);var _=V(d,2),v=e=>{var t=Ts(),n=z(t),r=R(n),i=V(r);let a;var o=B(i);M(n);var s=V(n,2),c=B(s),u=V(s,2),d=e=>{J(e,ws())};X(u,e=>{G(l).accounting_complete||e(d)}),Ce(2),H(e=>{Y(r,`${G(l).window_minutes??``} MIN // ${G(l).plan_type??``} // `),a=ci(i,1,`svelte-1fbui7h`,null,a,{muted:G(f)==null,"quota-near":G(f)!=null&&G(f)>=80&&G(f)<100,"quota-full":G(f)!=null&&G(f)>=100}),Y(o,`USED ${e??``}${G(f)==null?``:`%`}`),Y(c,`${G(l).starts_at??``} → ${G(l).ends_at??``}`)},[()=>m(G(f))]),J(e,t)};X(_,e=>{G(l)&&e(v)}),H(e=>{i=ci(r,1,`svelte-1fbui7h`,null,i,{"quality-warning":!G(a).coverage_complete}),Y(o,G(a).coverage_complete?`Reported range complete`:`Partial coverage`),Y(u,` // Latest fetch ${e??``}`),Y(p,`Coverage starts ${(G(a).coverage_start||`unknown`)??``} // Data as of ${(G(a).data_as_of||`unknown`)??``}`)},[()=>sa(G(a).fetchedAt)]),J(e,t)},oe=e=>{J(e,Ds())};X(O,e=>{t.mode===`breakdown`&&G(i)?e(ie):t.mode===`periods`&&G(a)?e(ae,1):e(oe,-1)});var se=V(O,2),ce=e=>{var n=ks(),r=R(n),i=B(V(R(r),2),!0);M(r),Z(V(r,2),17,()=>G(d),qr,(e,n,r)=>{var i=P(()=>_(G(n),2));let a=()=>G(i)[0],o=()=>G(i)[1];var s=Os();let c;var l=R(s),u=B(l,!0),d=V(l,2),f=R(d);let h;M(d);var g=B(V(d,2));M(s),H((e,n,i)=>{c=ci(s,1,`report-row svelte-1fbui7h`,null,c,{unknown:e,alternate:r%2==1}),Q(l,`title`,a()),Y(u,a()),h=ui(f,``,h,{width:n}),Y(g,`${i??``}${t.mode===`periods`?`%`:``}`)},[()=>a().trim().toLowerCase()===`unknown`,()=>`${Math.max(0,100*o()/G(p))}%`,()=>m(o())]),J(e,s)}),M(n),H(()=>Y(i,t.mode===`breakdown`?G(u):`QUOTA USED`)),J(e,n)},le=e=>{J(e,As())},ue=e=>{J(e,js())};X(se,e=>{G(d).length?e(ce):t.mode===`breakdown`&&G(c)?.total===0&&G(c)?.groups[G(n)]!=null?e(le,1):(G(i)&&t.mode===`breakdown`||G(a)&&t.mode===`periods`)&&e(ue,2)}),M(ee),H(()=>{w.disabled=G(s)>=G(o)-1,T.disabled=G(s)===0,Y(E,G(o)?`${G(s)+1} / ${G(o)}`:`NO DATA`),Y(te,t.mode===`breakdown`?`DAILY BREAKDOWN`:`QUOTA WINDOWS`),Y(re,`${(t.mode===`breakdown`?t.reports?.dailyStatus||`UNAVAILABLE`:t.reports?.planStatus||`UNAVAILABLE`)??``} // UTC`)}),hi(y,()=>G(n),e=>L(n,e)),K(`click`,w,()=>L(r,G(s)+1)),K(`click`,T,()=>L(r,G(s)-1)),J(e,h),Ge()}Cr([`click`]);var Ps=864e5;function Fs(e,t){let n=new Date(e);n.setUTCDate(1),n.setUTCMonth(n.getUTCMonth()+t);let r=new Date(Date.UTC(n.getUTCFullYear(),n.getUTCMonth()+1,0)).getUTCDate();return n.setUTCDate(Math.min(e.getUTCDate(),r)),n}function Is(e,t,n){let r=new Date(Date.UTC(e.getUTCFullYear(),e.getUTCMonth(),e.getUTCDate()));for(let e=0;;e++){let i=Fs(r,-t),a=new Date(i.getTime()+Ps);if(e===n)return{start:a,end:r};r=i}}var Ls=q(``),Rs=q(``,1),zs=q(`

        History refresh failed. Any displayed history is the last successful + observation.

        `),Bs=q(`

        Token history unavailable; other reports may still be available.

        `),Vs=q(``),Hs=q(`
        `),Us=q(`

        LESS ░ ▒ ▓ █ MORE // HOVER FOR DATE AND TOKENS

        `,1),Ws=q(`
        `,1),Gs=q(` `),Ks=q(`

        LIFETIME TOKENS

        PEAK DAY

        CURRENT STREAK

        DAYS

        LONGEST TURN

        SECONDS

        LONGEST STREAK

        DAYS

        OPENAI // UTC

        Accessible data table
        Date (UTC)Tokens

        `,1),qs=q(`

        History unavailable or awaiting a matching account observation. Missing + history is not treated as zero usage.

        `),Js=q(`

        USAGE // ACCOUNT HISTORY

        Account-wide history reported by Codex, not the local Sessions counter. Dates + use UTC. Allowance history is approximate where indicated; reset causes are + not provided.

        `,1);function Ys(e,t){We(t,!0);let n=I(`daily`),r=I(12),i=I(0),a=P(()=>{let e=$.data?.usage?.dailyUsageBuckets;if(!e)return[];let{start:t,end:n}=Is(new Date,G(r),G(i)),a=new Map;for(let t of e)!Number.isFinite(t.tokens)||t.tokens<0||a.set(t.startDate,(a.get(t.startDate)||0)+t.tokens);let o=[];for(let e=t;e<=n;e=new Date(e.getTime()+864e5)){let t=e.toISOString().slice(0,10);o.push({date:t,tokens:a.get(t)||0})}return o}),o=P(()=>Math.max(1,...G(a).map(e=>e.tokens))),s=P(()=>G(a).length?new Date(G(a)[0].date+`T00:00:00Z`).getUTCDay():0),c=P(()=>{if(G(n)===`monthly`){let e=new Map;for(let t of G(a))e.set(t.date.slice(0,7),(e.get(t.date.slice(0,7))||0)+t.tokens);return[...e].map(([e,t])=>({date:e,tokens:t}))}let e=0;return G(a).map(t=>({date:t.date,tokens:e+=t.tokens}))});var l=Js(),u=V(z(l),4);Z(u,20,()=>[[`daily`,`Daily`],[`monthly`,`Monthly`],[`cumulative`,`Cumulative`],[`breakdown`,`Breakdown`],[`periods`,`Windows`]],qr,(e,t)=>{var r=P(()=>_(t,2));let a=()=>G(r)[0],o=()=>G(r)[1];var s=Ls();let c;var l=B(s,!0);H(()=>{Q(s,`aria-pressed`,G(n)===a()),c=ci(s,1,``,null,c,{active:G(n)===a()}),Y(l,o())}),K(`click`,s,()=>{L(n,a(),!0),L(i,0)}),J(e,s)}),M(u);var d=V(u,2),f=R(d),p=e=>{var t=Rs(),n=z(t),a=V(R(n)),o=R(a);o.value=o.__value=6;var s=V(o);s.value=s.__value=12,M(a),mi(a),M(n);var c=V(n),l=V(c);H(()=>l.disabled=G(i)===0),K(`change`,a,()=>L(i,0)),hi(a,()=>G(r),e=>L(r,e)),K(`click`,c,()=>qt(i)),K(`click`,l,()=>qt(i,-1)),J(e,t)};X(f,e=>{G(n)!==`breakdown`&&G(n)!==`periods`&&e(p)}),M(d);var m=V(d,2),h=e=>{J(e,zs())};X(m,e=>{$.data?.usageError&&e(h)});var g=V(m,2),v=e=>{J(e,Bs())};X(g,e=>{$.data?.usage?.tokenStatus===`UNAVAILABLE`&&G(n)!==`breakdown`&&G(n)!==`periods`&&e(v)});var y=V(g,2),b=e=>{var t=Pr();Kr(z(t),()=>G(n),e=>{{let t=P(()=>$.data?.usage?.reports);Ns(e,{get reports(){return G(t)},get mode(){return G(n)}})}}),J(e,t)},x=e=>{var t=Ks(),r=z(t),i=R(r),l=B(V(R(i),2),!0);M(i);var u=V(i,2),d=B(V(R(u),2),!0);M(u);var f=V(u,2),p=V(R(f),2),m=R(p);Ce(),M(p),M(f);var h=V(f,2),g=V(R(h),2),_=R(g);Ce(),M(g),M(h);var v=V(h,2),y=V(R(v),2),b=R(y);Ce(),M(y),M(v),M(r);var x=V(r,4),S=R(x),C=B(S),w=V(S,2),T=e=>{var t=Us(),n=z(t),r=R(n),i=R(r);Z(i,17,()=>Array(G(s)),qr,(e,t)=>{J(e,Vs())}),Z(V(i,2),17,()=>G(a),qr,(e,t)=>{var n=Hs();let r,i;H(e=>{r=ci(n,1,`heat-cell`,null,r,{zero:G(t).tokens===0}),Q(n,`title`,e),i=ui(n,``,i,{opacity:G(t).tokens?.25+.75*G(t).tokens/G(o):1})},[()=>`${G(t).date}: ${oa(G(t).tokens)} tokens`]),J(e,n)}),M(r),M(n),Ce(2),J(e,t)},E=e=>{var t=Ws(),r=z(t);{let e=P(()=>G(c).map(e=>e.tokens)),t=P(()=>G(n)+` usage`);Ya(r,{get values(){return G(e)},get label(){return G(t)}})}var i=V(r,2),a=R(i),o=B(a,!0),s=B(V(a),!0);M(i),H(e=>{Y(o,G(c)[0]?.date),Y(s,e)},[()=>G(c).at(-1)?.date]),J(e,t)};X(w,e=>{G(n)===`daily`?e(T):e(E,-1)});var ee=V(w,2),D=V(R(ee),2),te=R(D),ne=V(R(te));Z(ne,21,()=>G(n)===`daily`?G(a):G(c),qr,(e,t)=>{var n=Gs(),r=R(n),i=B(r,!0),a=B(V(r),!0);M(n),H(e=>{Y(i,G(t).date),Y(a,e)},[()=>oa(G(t).tokens)]),J(e,n)}),M(ne),M(te),M(D),M(ee),M(x);var re=B(V(x,2));H((e,t,n,r,i,o,s)=>{Y(l,e),Y(d,t),Y(m,`${n??``} `),Y(_,`${r??``} `),Y(b,`${i??``} `),Y(C,`${G(a)[0]?.date??``} — ${o??``}`),Y(re,`OBSERVED ${s??``} // CUMULATIVE IS FOR THE SELECTED PERIOD`)},[()=>oa($.data.usage.summary.lifetimeTokens),()=>oa($.data.usage.summary.peakDailyTokens),()=>oa($.data.usage.summary.currentStreakDays),()=>oa($.data.usage.summary.longestRunningTurnSec),()=>oa($.data.usage.summary.longestStreakDays),()=>G(a).at(-1)?.date,()=>sa($.data.usageAt)]),J(e,t)},S=e=>{J(e,qs())};X(y,e=>{G(n)===`breakdown`||G(n)===`periods`?e(b):$.data?.usage&&$.data.usage.dailyUsageBuckets!==null?e(x,1):e(S,-1)}),J(e,l),Ge()}Cr([`click`,`change`]);var Xs=q(`

        Page not found

        Return to Quota

        `,1);function Zs(e){var t=Xs();Ce(2),J(e,t)}var Qs=q(` `),$s=q(`

        `),ec=q(`

        Connecting to your local Codexometer…

        `),tc=q(``),nc=q(`
        CODEXOMETER

        Your quota. Your sessions. Your command centre.

        `);function rc(e,t){We(t,!0);let n={"/":Ka,"/quota/:view?":Ka,"/sessions/:id?":bs,"/usage":Ys,"*":Zs},r={quota:/^(?:\/|\/quota(?:\/[^/]+)?\/?)$/,sessions:/^\/sessions(?:\/[^/]+)?\/?$/,usage:/^\/usage\/?$/},i=I(`hacker`),a=[`hacker`,`rust`,`blue-steel`,`ultraviolet`,`nightshade`];yn(()=>{ta()}),yn(()=>{if($.data)for(let[e,t]of Object.entries(r))t.test(Ki.location)&&(ea.tab=e)}),Ui(()=>{try{let e=localStorage.getItem(`codexometer.web.theme`);e&&a.includes(e)&&L(i,e,!0)}catch{}return fa()});function o(){try{localStorage.setItem(`codexometer.web.theme`,G(i))}catch{}}var s=nc(),c=R(s),l=V(R(c),2),u=R(l);let d;var f=V(u,1,!0),p=B(V(f));M(l),M(c);var m=V(c,2);Z(m,21,()=>Object.entries(r),qr,(e,t)=>{var n=P(()=>_(G(t),2));let r=()=>G(n)[0],i=()=>G(n)[1],a=P(()=>i().test(Ki.location));var o=Qs();let s;var c=B(o,!0);H(e=>{Q(o,`href`,r()===`quota`?`#/quota/`+ea.view:`#/`+r()),Q(o,`aria-current`,G(a)?`page`:void 0),s=ci(o,1,``,null,s,{active:G(a)}),Y(c,e)},[()=>r().toUpperCase()]),J(e,o)}),M(m);var h=V(m,2),g=R(h),v=e=>{var t=$s(),n=B(t,!0);H(()=>Y(n,$.error)),J(e,t)};X(g,e=>{$.error&&e(v)});var y=V(g,2),b=e=>{Yi(e,{get routes(){return n}})},x=e=>{J(e,ec())};X(y,e=>{$.data?e(b):$.error||e(x,1)}),M(h);var S=V(h,2),C=R(S),w=B(C),T=V(C,2),E=B(T,!0),ee=V(T,2),D=V(R(ee));Z(D,21,()=>a,qr,(e,t)=>{var n=tc(),r=B(n,!0),i={};H(e=>{Y(r,e),i!==(i=G(t))&&(n.value=(n.__value=i)??``)},[()=>G(t).replace(`-`,` `).toUpperCase()]),J(e,n)}),M(D),mi(D),M(ee),M(S),M(s),H(()=>{Q(s,`data-theme`,G(i)),d=ci(u,1,`lamp`,null,d,{lit:$.connected}),Y(f,$.connected?`CONNECTED`:`OFFLINE`),Y(p,`EXPERIMENTAL // ${$.data?.control?`SESSION CONTROL`:`READ ONLY`}`),Y(w,`v${($.data?.version||`…`)??``} // LOCAL ONLY`),Y(E,$.data?.control?`Session control enabled. Stop the server to revoke access.`:`No actions can be sent from this preview.`)}),K(`change`,D,o),hi(D,()=>G(i),e=>L(i,e)),J(e,s),Ge()}Cr([`change`]),Br(rc,{target:document.getElementById(`app`)}); \ No newline at end of file diff --git a/internal/web/dist/assets/index-CjLW2fqm.css b/internal/web/dist/assets/index-CjLW2fqm.css deleted file mode 100644 index b5cb577..0000000 --- a/internal/web/dist/assets/index-CjLW2fqm.css +++ /dev/null @@ -1 +0,0 @@ -.observation-details.svelte-1wk8llw{font-size:12px}summary.svelte-1wk8llw{cursor:pointer;color:var(--accent)}.observation-table.svelte-1wk8llw{max-height:240px;overflow:auto}.zone-canvas.svelte-1wk8llw{flex:1;min-height:170px;margin-top:6px;position:relative}svg.svelte-1wk8llw{width:100%;height:100%;display:block;position:absolute;inset:0}text.svelte-1wk8llw{fill:var(--ink);font:14px Cascadia Code,Consolas,monospace}.axis-title.svelte-1wk8llw{letter-spacing:.04em;font-size:12px}.grid.svelte-1wk8llw{stroke:#fff;stroke-opacity:.14;stroke-width:1px}.axes.svelte-1wk8llw{stroke:var(--ink);stroke-width:1.5px;fill:none}.pace-line.svelte-1wk8llw{stroke:#fff;stroke-width:2px;stroke-dasharray:6 4}.position-halo.svelte-1wk8llw{fill:#101820;stroke:#fff;stroke-width:1.5px}.position-dot.svelte-1wk8llw{fill:#fff}.observation-trail.svelte-1wk8llw{fill:none;stroke:#fff;stroke-width:2.5px;stroke-linejoin:round}.trail-start.svelte-1wk8llw{fill:none;stroke:#fff;stroke-width:2px}.trail-caption.svelte-1wk8llw{text-align:center;font-size:11px}.zone-caption.svelte-1wk8llw{color:var(--accent);text-align:center}fieldset.svelte-1oupzfc{border:1px solid;margin:.5rem 0;padding:.35rem .6rem}.decision.svelte-1oupzfc,.answer.svelte-1oupzfc{margin:.3rem 0;display:block}.decision.svelte-1oupzfc code:where(.svelte-1oupzfc),.decision.svelte-1oupzfc span:where(.svelte-1oupzfc){overflow-wrap:anywhere;margin:.3rem 0 .3rem 1.5rem;display:block}textarea.svelte-1oupzfc,.answer.svelte-1oupzfc input:where(.svelte-1oupzfc),.answer.svelte-1oupzfc select:where(.svelte-1oupzfc){box-sizing:border-box;background:var(--bg);width:100%;color:inherit;font:inherit;border:1px solid;margin-top:.4rem;padding:.5rem;display:block}textarea.svelte-1oupzfc{resize:vertical}button.svelte-1oupzfc{margin:.3rem .5rem .3rem 0}h3.svelte-1oupzfc,p.svelte-1oupzfc{margin-block:.4rem}.sent.svelte-1oupzfc{color:var(--accent)}.session-copy.svelte-543j00{justify-content:flex-end;align-items:center;gap:8px;margin-top:auto;padding-top:6px;display:flex}span.svelte-543j00{color:var(--muted);overflow-wrap:anywhere;font-size:12px}button.svelte-543j00{color:var(--accent);flex-shrink:0;padding:3px 7px}button.svelte-543j00:hover,button.flashed.svelte-543j00{color:var(--bg);background:var(--accent)}h3.svelte-1fbui7h,.window-dates.svelte-1fbui7h,.report-value.svelte-1fbui7h{color:var(--accent);font-weight:700}.quality-warning.svelte-1fbui7h,.quota-near.svelte-1fbui7h{color:var(--approval)}.quota-full.svelte-1fbui7h{color:#ff6680}.alternate.svelte-1fbui7h .report-fill:where(.svelte-1fbui7h){background:radial-gradient(circle, var(--panel) .7px, transparent .9px) 0 0 / 3px 3px, var(--accent)}.unknown.svelte-1fbui7h,.unknown.svelte-1fbui7h .report-value:where(.svelte-1fbui7h){color:var(--muted)}.unknown.svelte-1fbui7h .report-fill:where(.svelte-1fbui7h){background:var(--muted);opacity:1}.report-bars.svelte-1fbui7h{grid-template-columns:minmax(5rem,1fr) minmax(2rem,3fr) auto;gap:.6rem;display:grid}.report-row.svelte-1fbui7h,.report-head.svelte-1fbui7h{grid-column:1/-1;grid-template-columns:subgrid;align-items:center;gap:.6rem;display:grid}.report-head.svelte-1fbui7h{color:var(--muted);font-size:.85rem}.report-head.svelte-1fbui7h>span:where(.svelte-1fbui7h):last-child,.report-value.svelte-1fbui7h{text-align:right}.report-row.svelte-1fbui7h>span:where(.svelte-1fbui7h):first-child{overflow-wrap:anywhere}.report-track.svelte-1fbui7h{background:var(--panel);height:1rem}.report-fill.svelte-1fbui7h{background:var(--accent);height:100%}:root{font-synthesis:none;--lightningcss-light: ;--lightningcss-dark:initial;color-scheme:dark;background:#080e0c;font-family:Cascadia Code,SFMono-Regular,Consolas,monospace}*{box-sizing:border-box}body{margin:0}.shell{--accent:#80edac;--approval:#ffca58;--muted:#98aaa0;--edge:#31473a;--panel:#101c16;--bg:#080e0c;--ink:#e2eee5;background:var(--bg);height:100dvh;color:var(--ink);grid-template-rows:auto auto minmax(0,1fr) auto;padding:clamp(10px,1.2vw,20px);display:grid}.shell[data-theme=rust]{--approval:#ff8a3d;--accent:#ffbc75;--muted:#bfac95;--edge:#59412c;--panel:#261b14;--bg:#140e0a}.shell[data-theme=blue-steel]{--approval:#e8c46a;--accent:#85c8ff;--muted:#9cacc4;--edge:#334d69;--panel:#142235;--bg:#0b1421}.shell[data-theme=ultraviolet]{--approval:#f9a8d4;--accent:#c5adff;--muted:#b3a6c9;--edge:#4b3c68;--panel:#241a35;--bg:#140d22}.shell[data-theme=nightshade]{--approval:#8f7cff;--accent:#e59bff;--muted:#c6a7cf;--edge:#673976;--panel:#301739;--bg:#1c0b24}header,.spread,footer,.controls{flex-wrap:wrap;justify-content:space-between;align-items:center;gap:10px;display:flex}header{margin-bottom:10px}.brand{letter-spacing:.08em;color:var(--accent);text-shadow:0 0 22px color-mix(in srgb, var(--accent) 20%, transparent);font-size:clamp(22px,2.5vw,32px);font-weight:800;text-decoration:none}header p{color:var(--muted);margin:3px 0 0;font-size:13px}.connection{color:var(--accent);text-align:right}small,.eyebrow{letter-spacing:.04em;font-size:12px}.connection small{color:var(--muted);margin-top:4px;display:block}.lamp{background:var(--muted);border-radius:50%;width:9px;height:9px;margin-right:9px;display:inline-block}.lamp.lit{background:var(--accent);box-shadow:0 0 8px color-mix(in srgb, var(--accent) 40%, transparent)}.working{animation:1.5s ease-in-out infinite pulse}@keyframes pulse{50%{opacity:.3}}nav{flex-wrap:wrap;gap:6px;margin-bottom:10px;display:flex}nav a,button,.button,select{font:inherit;color:var(--accent);border:1px solid var(--edge);background:var(--panel);cursor:pointer;border-radius:3px;padding:7px 11px;font-size:13px;text-decoration:none}button:hover,.button:hover,nav a:hover{border-color:var(--accent)}nav a.active,nav button.active{background:var(--accent);color:var(--bg);border-color:var(--accent)}:focus-visible{outline:2px solid var(--accent);outline-offset:4px}.secondary{margin-bottom:8px}.secondary a,.secondary button{padding:6px 10px;font-size:12px}main{scrollbar-gutter:stable;flex-direction:column;min-width:0;min-height:0;display:flex;overflow:auto}main>*{flex-shrink:0}.panel{border:1px solid var(--edge);border-top:2px solid var(--accent);background:var(--panel);border-radius:4px;min-width:0;padding:clamp(10px,1vw,16px)}h1{color:var(--accent);font-size:20px}h2{letter-spacing:.04em;color:var(--accent);overflow-wrap:anywhere;margin:0 0 10px;font-size:13px}h3{overflow-wrap:anywhere;font-size:13px}p{margin:6px 0;font-size:13px;line-height:1.45}a{color:var(--accent)}.muted,.eyebrow{color:var(--muted)}.notice{color:#ffe0aa;background:#332611;border-left:3px solid #ffc26e;padding:12px 16px}.empty{color:var(--muted);padding:35px 0}.quota-grid{flex:1 0 auto;grid-auto-rows:minmax(220px,1fr);gap:10px;display:grid}.quota-card{flex-direction:column;min-height:0;display:flex}.quota-card>:not(.meter-graphic){flex-shrink:0}.meter-graphic{flex-direction:column;flex:1;min-height:100px;display:flex}.bar-graphic{min-height:65px}.quota-card .gauge{flex:2;height:auto;min-height:16px;margin:7px 0}.quota-card .timeline{flex:1;min-height:8px}.quota-card .pace{flex:1;height:auto;min-height:20px;margin:20px 14px 8px}.quota-card .readout{margin:6px 0;font-size:clamp(18px,2vw,28px)}.quota-grid.zone{grid-template-columns:repeat(auto-fit,minmax(min(100%,420px),1fr));grid-auto-rows:minmax(330px,1fr)}.quota-grid.radial{grid-template-columns:repeat(auto-fit,minmax(min(100%,300px),1fr));grid-auto-rows:minmax(260px,1fr)}.gauge{background:var(--edge);height:35px;margin:15px 0;overflow:hidden}.gauge>div{background:repeating-linear-gradient(90deg, var(--accent) 0, var(--accent) 9px, transparent 9px, transparent 12px);height:100%;transition:width .5s}.gauge.timeline{height:14px}.pie-wrap{flex:1;min-height:100px;margin:6px 0;position:relative}.pie-wrap svg{width:100%;height:100%;position:absolute;inset:0}.pie-base{fill:var(--edge);stroke:var(--accent);stroke-width:1px}.pie-fill{fill:var(--accent)}.pace{background:linear-gradient(90deg, #6d3838, var(--edge) 50%, #376348);height:28px;margin:28px 14px 15px;position:relative}.pace-mid{border-left:2px solid var(--ink);height:100%;position:absolute;left:50%}.pace-marker{color:var(--accent);font-size:27px;position:absolute;top:-16px;transform:translate(-50%)}.readout{overflow-wrap:anywhere;color:var(--accent);margin:18px 0;font-size:clamp(22px,3vw,34px)}.credit+.credit{border-top:1px solid var(--edge);padding-top:20px}.credit{margin-top:24px}.session-row{grid-template-columns:minmax(210px,1.1fr) minmax(0,1.2fr) minmax(0,1.5fr);align-items:stretch;gap:14px;margin:18px 0;display:grid}.session-totals{grid-template-columns:repeat(auto-fit,minmax(min(145px,100%),1fr));gap:8px;margin:8px 0;display:grid}.session-totals>div{border:1px solid var(--edge);min-width:0;padding:10px}.session-totals dt{color:var(--muted);font-size:11px}.session-totals dd{color:var(--accent);overflow-wrap:anywhere;margin:6px 0 0;font-size:24px}.session-totals.stale dd{color:var(--muted)}.session-row.selected{outline:1px solid var(--accent);outline-offset:4px}.session-row.wide .context{grid-column:2/-1}.detail-controls{flex-wrap:wrap;gap:6px;display:flex}.attention-summary{flex-wrap:wrap;gap:8px;margin-block:8px;display:flex}.attention-summary .approval{color:var(--approval);border-color:var(--approval)}.attention-summary .approval:hover{background:color-mix(in srgb, var(--approval) 12%, var(--panel))}.attention-summary .approval:focus-visible{outline-color:var(--approval)}.attention-note,.attention-badge{color:var(--accent);border-left:3px solid;padding-left:8px}.attention-note.inferred{color:var(--muted)}.attention-badge{font-size:12px}.session-select{text-align:left;overflow-wrap:anywhere;max-width:100%}.status-detail{color:inherit;text-decoration:none}.status-detail:hover{text-decoration:underline}button:disabled{opacity:.4;cursor:default}.session-row .graph-panel{flex-direction:column;grid-column:2/-1;display:flex}.session-row.split .graph-panel{grid-column:auto}.session-row .button,.session-row button{margin-top:8px;display:inline-block}.context pre{max-height:230px;overflow:auto}.session-row .context{flex-direction:column;display:flex}pre{font:inherit;white-space:pre-wrap;overflow-wrap:anywhere;font-size:13px;line-height:1.7}.full-detail{margin-top:6px}.detail-heading{flex-wrap:wrap;justify-content:space-between;align-items:center;gap:6px 12px;display:flex}.detail-heading h2{flex:250px;min-width:0;margin:0}.detail-metadata{overflow-wrap:anywhere}.detail-workspace{border-top:1px solid var(--edge);flex-direction:column;gap:10px;margin-top:8px;padding-top:4px;display:flex}.detail-context,.detail-workspace .session-actions{min-width:0}.detail-workspace .session-actions{border-top:1px solid var(--edge);padding-top:6px}.full-detail h3{margin-block:8px}.full-detail pre{margin-block:8px;line-height:1.5}.full-detail hr{margin-block:12px}.full-detail .command,.full-detail .notice{padding:8px 10px}.command{background:var(--bg);border-left:3px solid var(--accent);padding:16px}hr{border:0;border-top:1px solid var(--edge);margin:24px 0}.chart{border-bottom:1px solid var(--muted);align-items:flex-end;gap:2px;height:clamp(120px,22vh,320px);margin-top:18px;display:flex}.chart-bar{background:var(--accent);flex:1;min-width:0;max-height:100%}.controls{justify-content:flex-start;margin:20px 0}.summary-grid{grid-template-columns:repeat(auto-fit,minmax(min(100%,220px),1fr));gap:16px;margin:20px 0;display:grid}.thresholds-panel{padding:clamp(14px,2vw,22px)}.thresholds-panel h1{margin-top:0}.threshold-list{gap:8px;margin-top:16px;display:grid}.threshold-list article{border:1px solid var(--edge);grid-template-columns:minmax(64px,.5fr) minmax(220px,3fr) minmax(64px,.6fr) minmax(120px,1.2fr);align-items:center;gap:12px;padding:10px 12px;display:grid}.threshold-list article.active{border-color:var(--accent);box-shadow:inset 3px 0 0 var(--accent)}.threshold-list article.next{border-color:var(--approval);box-shadow:inset 3px 0 0 var(--approval)}.threshold-list p{color:var(--muted);margin:3px 0 0;font-size:12px}.threshold-trigger{color:var(--accent);font-size:22px;font-weight:800}.threshold-mode,.threshold-state{color:var(--muted);font-size:12px;font-weight:700}.threshold-list article.active .threshold-state{color:var(--accent)}.threshold-list article.next .threshold-state{color:var(--approval)}.heat-scroll{overflow-x:auto}.heatmap{grid-template-rows:repeat(7,13px);grid-auto-columns:minmax(9px,1fr);grid-auto-flow:column;gap:4px;min-width:620px;margin:25px 0;display:grid}.heat-cell{background:var(--accent);border-radius:2px}.heat-cell.zero{background:var(--edge)}.table-scroll{max-height:280px;overflow:auto}table{border-collapse:collapse;width:100%;margin-top:15px}th,td{text-align:left;border-bottom:1px solid var(--edge);padding:8px}summary{cursor:pointer;color:var(--accent);padding-top:14px}footer{border-top:1px solid var(--edge);color:var(--muted);margin-top:8px;padding-top:8px;font-size:11px}footer select{padding:6px}@media (width<=850px){.session-row{grid-template-columns:minmax(0,1fr)}.session-row.wide .context,.session-row .graph-panel{grid-column:auto}.connection{text-align:left}.threshold-list article{grid-template-columns:64px minmax(0,1fr)}.threshold-mode,.threshold-state{grid-column:2}}@media (prefers-reduced-motion:reduce){*,:before,:after{transition:none!important;animation:none!important}} diff --git a/internal/web/dist/assets/index-CnNJF8v9.css b/internal/web/dist/assets/index-CnNJF8v9.css new file mode 100644 index 0000000..4c9fb1f --- /dev/null +++ b/internal/web/dist/assets/index-CnNJF8v9.css @@ -0,0 +1 @@ +.observation-details.svelte-1wk8llw{font-size:12px}summary.svelte-1wk8llw{cursor:pointer;color:var(--accent)}.observation-table.svelte-1wk8llw{max-height:240px;overflow:auto}.zone-canvas.svelte-1wk8llw{flex:1;min-height:170px;margin-top:6px;position:relative}svg.svelte-1wk8llw{width:100%;height:100%;display:block;position:absolute;inset:0}text.svelte-1wk8llw{fill:var(--ink);font:14px Cascadia Code,Consolas,monospace}.axis-title.svelte-1wk8llw{letter-spacing:.04em;font-size:12px}.grid.svelte-1wk8llw{stroke:#fff;stroke-opacity:.14;stroke-width:1px}.axes.svelte-1wk8llw{stroke:var(--ink);stroke-width:1.5px;fill:none}.pace-line.svelte-1wk8llw{stroke:#fff;stroke-width:2px;stroke-dasharray:6 4}.position-halo.svelte-1wk8llw{fill:#101820;stroke:#fff;stroke-width:1.5px}.position-dot.svelte-1wk8llw{fill:#fff}.observation-trail.svelte-1wk8llw{fill:none;stroke:#fff;stroke-width:2.5px;stroke-linejoin:round}.trail-start.svelte-1wk8llw{fill:none;stroke:#fff;stroke-width:2px}.trail-caption.svelte-1wk8llw{text-align:center;font-size:11px}.zone-caption.svelte-1wk8llw{color:var(--accent);text-align:center}.saved-prompt.svelte-1oupzfc{white-space:pre-wrap;overflow-wrap:anywhere}fieldset.svelte-1oupzfc{border:1px solid;margin:.5rem 0;padding:.35rem .6rem}.decision.svelte-1oupzfc,.answer.svelte-1oupzfc{margin:.3rem 0;display:block}.decision.svelte-1oupzfc code:where(.svelte-1oupzfc),.decision.svelte-1oupzfc span:where(.svelte-1oupzfc){overflow-wrap:anywhere;margin:.3rem 0 .3rem 1.5rem;display:block}textarea.svelte-1oupzfc,.answer.svelte-1oupzfc input:where(.svelte-1oupzfc),.answer.svelte-1oupzfc select:where(.svelte-1oupzfc){box-sizing:border-box;background:var(--bg);width:100%;color:inherit;font:inherit;border:1px solid;margin-top:.4rem;padding:.5rem;display:block}textarea.svelte-1oupzfc{resize:vertical;background:color-mix(in srgb, currentColor 8%, var(--bg))}button.svelte-1oupzfc{margin:.3rem .5rem .3rem 0}h3.svelte-1oupzfc,p.svelte-1oupzfc{margin-block:.4rem}.sent.svelte-1oupzfc{color:var(--accent)}.session-copy.svelte-543j00{justify-content:flex-end;align-items:center;gap:8px;margin-top:auto;padding-top:6px;display:flex}span.svelte-543j00{color:var(--muted);overflow-wrap:anywhere;font-size:12px}button.svelte-543j00{color:var(--accent);flex-shrink:0;padding:3px 7px}button.svelte-543j00:hover,button.flashed.svelte-543j00{color:var(--bg);background:var(--accent)}h3.svelte-1fbui7h,.window-dates.svelte-1fbui7h,.report-value.svelte-1fbui7h{color:var(--accent);font-weight:700}.quality-warning.svelte-1fbui7h,.quota-near.svelte-1fbui7h{color:var(--approval)}.quota-full.svelte-1fbui7h{color:#ff6680}.alternate.svelte-1fbui7h .report-fill:where(.svelte-1fbui7h){background:radial-gradient(circle, var(--panel) .7px, transparent .9px) 0 0 / 3px 3px, var(--accent)}.unknown.svelte-1fbui7h,.unknown.svelte-1fbui7h .report-value:where(.svelte-1fbui7h){color:var(--muted)}.unknown.svelte-1fbui7h .report-fill:where(.svelte-1fbui7h){background:var(--muted);opacity:1}.report-bars.svelte-1fbui7h{grid-template-columns:minmax(5rem,1fr) minmax(2rem,3fr) auto;gap:.6rem;display:grid}.report-row.svelte-1fbui7h,.report-head.svelte-1fbui7h{grid-column:1/-1;grid-template-columns:subgrid;align-items:center;gap:.6rem;display:grid}.report-head.svelte-1fbui7h{color:var(--muted);font-size:.85rem}.report-head.svelte-1fbui7h>span:where(.svelte-1fbui7h):last-child,.report-value.svelte-1fbui7h{text-align:right}.report-row.svelte-1fbui7h>span:where(.svelte-1fbui7h):first-child{overflow-wrap:anywhere}.report-track.svelte-1fbui7h{background:var(--panel);height:1rem}.report-fill.svelte-1fbui7h{background:var(--accent);height:100%}:root{font-synthesis:none;--lightningcss-light: ;--lightningcss-dark:initial;color-scheme:dark;background:#080e0c;font-family:Cascadia Code,SFMono-Regular,Consolas,monospace}*{box-sizing:border-box}body{margin:0}.shell{--accent:#80edac;--approval:#ffca58;--muted:#98aaa0;--edge:#31473a;--panel:#101c16;--bg:#080e0c;--ink:#e2eee5;background:var(--bg);height:100dvh;color:var(--ink);grid-template-rows:auto auto minmax(0,1fr) auto;padding:clamp(10px,1.2vw,20px);display:grid}.shell[data-theme=rust]{--approval:#ff8a3d;--accent:#ffbc75;--muted:#bfac95;--edge:#59412c;--panel:#261b14;--bg:#140e0a}.shell[data-theme=blue-steel]{--approval:#e8c46a;--accent:#85c8ff;--muted:#9cacc4;--edge:#334d69;--panel:#142235;--bg:#0b1421}.shell[data-theme=ultraviolet]{--approval:#f9a8d4;--accent:#c5adff;--muted:#b3a6c9;--edge:#4b3c68;--panel:#241a35;--bg:#140d22}.shell[data-theme=nightshade]{--approval:#8f7cff;--accent:#e59bff;--muted:#c6a7cf;--edge:#673976;--panel:#301739;--bg:#1c0b24}header,.spread,footer,.controls{flex-wrap:wrap;justify-content:space-between;align-items:center;gap:10px;display:flex}header{margin-bottom:10px}.brand{letter-spacing:.08em;color:var(--accent);text-shadow:0 0 22px color-mix(in srgb, var(--accent) 20%, transparent);font-size:clamp(22px,2.5vw,32px);font-weight:800;text-decoration:none}header p{color:var(--muted);margin:3px 0 0;font-size:13px}.connection{color:var(--accent);text-align:right}small,.eyebrow{letter-spacing:.04em;font-size:12px}.connection small{color:var(--muted);margin-top:4px;display:block}.lamp{background:var(--muted);border-radius:50%;width:9px;height:9px;margin-right:9px;display:inline-block}.lamp.lit{background:var(--accent);box-shadow:0 0 8px color-mix(in srgb, var(--accent) 40%, transparent)}.working{animation:1.5s ease-in-out infinite pulse}@keyframes pulse{50%{opacity:.3}}nav{flex-wrap:wrap;gap:6px;margin-bottom:10px;display:flex}nav a,button,.button,select{font:inherit;color:var(--accent);border:1px solid var(--edge);background:var(--panel);cursor:pointer;border-radius:3px;padding:7px 11px;font-size:13px;text-decoration:none}button:hover,.button:hover,nav a:hover{border-color:var(--accent)}nav a.active,nav button.active{background:var(--accent);color:var(--bg);border-color:var(--accent)}:focus-visible{outline:2px solid var(--accent);outline-offset:4px}.secondary{margin-bottom:8px}.secondary a,.secondary button{padding:6px 10px;font-size:12px}main{scrollbar-gutter:stable;flex-direction:column;min-width:0;min-height:0;display:flex;overflow:auto}main>*{flex-shrink:0}.panel{border:1px solid var(--edge);border-top:2px solid var(--accent);background:var(--panel);border-radius:4px;min-width:0;padding:clamp(10px,1vw,16px)}h1{color:var(--accent);font-size:20px}h2{letter-spacing:.04em;color:var(--accent);overflow-wrap:anywhere;margin:0 0 10px;font-size:13px}h3{overflow-wrap:anywhere;font-size:13px}p{margin:6px 0;font-size:13px;line-height:1.45}a{color:var(--accent)}.muted,.eyebrow{color:var(--muted)}.notice{color:#ffe0aa;background:#332611;border-left:3px solid #ffc26e;padding:12px 16px}.empty{color:var(--muted);padding:35px 0}.quota-grid{flex:1 0 auto;grid-auto-rows:minmax(220px,1fr);gap:10px;display:grid}.quota-card{flex-direction:column;min-height:0;display:flex}.quota-card>:not(.meter-graphic){flex-shrink:0}.meter-graphic{flex-direction:column;flex:1;min-height:100px;display:flex}.bar-graphic{min-height:65px}.quota-card .gauge{flex:2;height:auto;min-height:16px;margin:7px 0}.quota-card .timeline{flex:1;min-height:8px}.quota-card .pace{flex:1;height:auto;min-height:20px;margin:20px 14px 8px}.quota-card .readout{margin:6px 0;font-size:clamp(18px,2vw,28px)}.quota-grid.zone{grid-template-columns:repeat(auto-fit,minmax(min(100%,420px),1fr));grid-auto-rows:minmax(330px,1fr)}.quota-grid.radial{grid-template-columns:repeat(auto-fit,minmax(min(100%,300px),1fr));grid-auto-rows:minmax(260px,1fr)}.gauge{background:var(--edge);height:35px;margin:15px 0;overflow:hidden}.gauge>div{background:repeating-linear-gradient(90deg, var(--accent) 0, var(--accent) 9px, transparent 9px, transparent 12px);height:100%;transition:width .5s}.gauge.timeline{height:14px}.pie-wrap{flex:1;min-height:100px;margin:6px 0;position:relative}.pie-wrap svg{width:100%;height:100%;position:absolute;inset:0}.pie-base{fill:var(--edge);stroke:var(--accent);stroke-width:1px}.pie-fill{fill:var(--accent)}.pace{background:linear-gradient(90deg, #6d3838, var(--edge) 50%, #376348);height:28px;margin:28px 14px 15px;position:relative}.pace-mid{border-left:2px solid var(--ink);height:100%;position:absolute;left:50%}.pace-marker{color:var(--accent);font-size:27px;position:absolute;top:-16px;transform:translate(-50%)}.readout{overflow-wrap:anywhere;color:var(--accent);margin:18px 0;font-size:clamp(22px,3vw,34px)}.credit+.credit{border-top:1px solid var(--edge);padding-top:20px}.credit{margin-top:24px}.session-row{grid-template-columns:minmax(210px,1.1fr) minmax(0,1.2fr) minmax(0,1.5fr);align-items:stretch;gap:14px;margin:18px 0;display:grid}.session-totals{grid-template-columns:repeat(auto-fit,minmax(min(145px,100%),1fr));gap:8px;margin:8px 0;display:grid}.session-totals>div{border:1px solid var(--edge);min-width:0;padding:10px}.session-totals dt{color:var(--muted);font-size:11px}.session-totals dd{color:var(--accent);overflow-wrap:anywhere;margin:6px 0 0;font-size:24px}.session-totals.stale dd{color:var(--muted)}.session-row.selected{outline:1px solid var(--accent);outline-offset:4px}.session-row.wide .context{grid-column:2/-1}.detail-controls{flex-wrap:wrap;gap:6px;display:flex}.attention-summary{flex-wrap:wrap;gap:8px;margin-block:8px;display:flex}.attention-summary .approval{color:var(--approval);border-color:var(--approval)}.attention-summary .approval:hover{background:color-mix(in srgb, var(--approval) 12%, var(--panel))}.attention-summary .approval:focus-visible{outline-color:var(--approval)}.attention-note,.attention-badge{color:var(--accent);border-left:3px solid;padding-left:8px}.attention-note.inferred{color:var(--muted)}.attention-badge{font-size:12px}.session-select{text-align:left;overflow-wrap:anywhere;max-width:100%}.status-detail{color:inherit;text-decoration:none}.status-detail:hover{text-decoration:underline}button:disabled{opacity:.4;cursor:default}.session-row .graph-panel{flex-direction:column;grid-column:2/-1;display:flex}.session-row.split .graph-panel{grid-column:auto}.session-row .button,.session-row button{margin-top:8px;display:inline-block}.context pre{max-height:230px;overflow:auto}.session-row .context{flex-direction:column;display:flex}pre{font:inherit;white-space:pre-wrap;overflow-wrap:anywhere;font-size:13px;line-height:1.7}.full-detail{margin-top:6px}.detail-heading{flex-wrap:wrap;justify-content:space-between;align-items:center;gap:6px 12px;display:flex}.detail-heading h2{flex:250px;min-width:0;margin:0}.detail-metadata{overflow-wrap:anywhere}.detail-workspace{border-top:1px solid var(--edge);flex-direction:column;gap:10px;margin-top:8px;padding-top:4px;display:flex}.detail-context,.detail-workspace .session-actions{min-width:0}.detail-workspace .session-actions{border-top:1px solid var(--edge);padding-top:6px}.full-detail h3{margin-block:8px}.full-detail pre{margin-block:8px;line-height:1.5}.full-detail hr{margin-block:12px}.full-detail .command,.full-detail .notice{padding:8px 10px}.command{background:var(--bg);border-left:3px solid var(--accent);padding:16px}hr{border:0;border-top:1px solid var(--edge);margin:24px 0}.chart{border-bottom:1px solid var(--muted);align-items:flex-end;gap:2px;height:clamp(120px,22vh,320px);margin-top:18px;display:flex}.chart-bar{background:var(--accent);flex:1;min-width:0;max-height:100%}.controls{justify-content:flex-start;margin:20px 0}.summary-grid{grid-template-columns:repeat(auto-fit,minmax(min(100%,220px),1fr));gap:16px;margin:20px 0;display:grid}.thresholds-panel{padding:clamp(14px,2vw,22px)}.thresholds-panel h1{margin-top:0}.threshold-list{gap:8px;margin-top:16px;display:grid}.threshold-list article{border:1px solid var(--edge);grid-template-columns:minmax(64px,.5fr) minmax(220px,3fr) minmax(64px,.6fr) minmax(120px,1.2fr);align-items:center;gap:12px;padding:10px 12px;display:grid}.threshold-list article.active{border-color:var(--accent);box-shadow:inset 3px 0 0 var(--accent)}.threshold-list article.next{border-color:var(--approval);box-shadow:inset 3px 0 0 var(--approval)}.threshold-list p{color:var(--muted);margin:3px 0 0;font-size:12px}.threshold-trigger{color:var(--accent);font-size:22px;font-weight:800}.threshold-mode,.threshold-state{color:var(--muted);font-size:12px;font-weight:700}.threshold-list article.active .threshold-state{color:var(--accent)}.threshold-list article.next .threshold-state{color:var(--approval)}.heat-scroll{overflow-x:auto}.heatmap{grid-template-rows:repeat(7,13px);grid-auto-columns:minmax(9px,1fr);grid-auto-flow:column;gap:4px;min-width:620px;margin:25px 0;display:grid}.heat-cell{background:var(--accent);border-radius:2px}.heat-cell.zero{background:var(--edge)}.table-scroll{max-height:280px;overflow:auto}table{border-collapse:collapse;width:100%;margin-top:15px}th,td{text-align:left;border-bottom:1px solid var(--edge);padding:8px}summary{cursor:pointer;color:var(--accent);padding-top:14px}footer{border-top:1px solid var(--edge);color:var(--muted);margin-top:8px;padding-top:8px;font-size:11px}footer select{padding:6px}@media (width<=850px){.session-row{grid-template-columns:minmax(0,1fr)}.session-row.wide .context,.session-row .graph-panel{grid-column:auto}.connection{text-align:left}.threshold-list article{grid-template-columns:64px minmax(0,1fr)}.threshold-mode,.threshold-state{grid-column:2}}@media (prefers-reduced-motion:reduce){*,:before,:after{transition:none!important;animation:none!important}} diff --git a/internal/web/dist/index.html b/internal/web/dist/index.html index 5691482..a7a9e1a 100644 --- a/internal/web/dist/index.html +++ b/internal/web/dist/index.html @@ -5,8 +5,8 @@ Codexometer // Experimental web - - + +
        diff --git a/internal/web/schedules.go b/internal/web/schedules.go new file mode 100644 index 0000000..5aa003f --- /dev/null +++ b/internal/web/schedules.go @@ -0,0 +1,172 @@ +package web + +import ( + "context" + "encoding/json" + "net/http" + "slices" + "time" + + "github.com/merefield/codexometer/internal/codex" + "github.com/merefield/codexometer/internal/schedule" +) + +type scheduleRule struct { + Trigger string `json:"trigger"` + At time.Time `json:"at"` + Zone string `json:"zone"` +} + +func (r scheduleRule) valid(now time.Time) bool { + return len(r.Zone) <= 128 && ((r.Trigger == "quota" && r.At.IsZero()) || (r.Trigger == "at" && r.At.After(now) && r.At.Before(now.AddDate(1, 0, 0)))) +} + +type triggerSummary struct { + Session string `json:"session"` + Status string `json:"status"` +} + +func (c *control) scheduleSnapshot() codex.Snapshot { + c.store.mu.Lock() + defer c.store.mu.Unlock() + if c.store.state.QuotaError { + return codex.Snapshot{} + } + return c.store.scheduleQuota +} +func (c *control) scheduleBlocked(session string) bool { + c.store.mu.Lock() + defer c.store.mu.Unlock() + if c.store.state.ProfileError { + return true + } + for _, p := range c.store.state.Profiles { + if p.Session == session && (p.Pending || p.Notice != "") { + return true + } + } + return false +} +func (c *control) publishSchedules() { + jobs := c.schedules.List("") + c.store.mu.Lock() + defer c.store.mu.Unlock() + var next []triggerSummary + for _, j := range jobs { + if j.Status != "sent" { + next = append(next, triggerSummary{j.Session, j.Status}) + } + } + if slices.Equal(c.store.state.Triggers, next) { + return + } + c.store.state.Triggers = next + c.store.publish() +} +func (c *control) handleSchedules(w http.ResponseWriter, r *http.Request, b actionRequest) { + if b.EditID != "" || b.SendID != "" || b.Schedule != nil || b.Offer != "" || b.Choice != nil || len(b.Answers) > 0 || b.Review != "" || b.Confirmation != "" { + http.Error(w, "Invalid schedule request", 400) + return + } + if b.CancelID != "" { + if err := c.schedules.Cancel(b.Session, b.CancelID); err != nil { + http.Error(w, err.Error(), 409) + return + } + c.publishSchedules() + } + w.Header().Set("Content-Type", "application/json") + type visibleJob struct { + schedule.Job + CanSend bool `json:"canSend"` + WaitingReason string `json:"waitingReason"` + } + jobs := []visibleJob{} + o, _ := c.offer(b.Session) + for _, j := range c.schedules.List(b.Session) { + _, ready := c.sendNowJob(b.Session, j.ID) + canSend := ready && o.Kind == "prompt" && o.Thread == b.Session && len(o.Questions) == 0 && o.ID != "" + reason := "waiting for an eligible idle session" + q, now := c.scheduleSnapshot(), time.Now() + switch { + case !schedule.Fresh(q, now): + reason = "waiting for fresh quota" + case !j.MatchesAccount(q.AccountFingerprint): + reason = "waiting for the original account" + case !schedule.Covers(j, q): + reason = "waiting for observed quota windows" + case !schedule.QuotaReady(q, now): + reason = "waiting for quota" + case c.scheduleBlocked(j.Session): + reason = "waiting for profile review or verification" + case canSend: + reason = "ready; awaiting dispatch" + } + jobs = append(jobs, visibleJob{j, canSend, reason}) + } + _ = json.NewEncoder(w).Encode(jobs) +} + +func (c *control) sendNowJob(session, id string) (schedule.Job, bool) { + q := c.scheduleSnapshot() + if !schedule.QuotaReady(q, time.Now()) || c.scheduleBlocked(session) { + return schedule.Job{}, false + } + for _, j := range c.schedules.List(session) { + if j.ID == id && j.Status == "pending" && j.MatchesAccount(q.AccountFingerprint) && schedule.Covers(j, q) { + return j, true + } + } + return schedule.Job{}, false +} +func (c *control) collectSchedules(ctx context.Context) func() { + done := make(chan struct{}) + go func() { + defer close(done) + ticker := time.NewTicker(time.Second) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + c.dispatchSchedules(ctx) + } + } + }() + return func() { <-done } +} +func (c *control) dispatchSchedules(ctx context.Context) { + if c.prompts == nil { + return + } + q := c.scheduleSnapshot() + if !schedule.QuotaReady(q, time.Now()) { + return + } + c.mu.Lock() + confirming := c.pending != nil && time.Now().Before(c.pending.until) + c.mu.Unlock() + if confirming { + return + } + for _, j := range c.schedules.List("") { + if j.Status != "pending" || c.scheduleBlocked(j.Session) || !schedule.Covers(j, q) || !schedule.Fresh(q, time.Now()) { + continue + } + o, err := c.offer(j.Session) + if err != nil || o.Kind != "prompt" || o.Thread != j.Session || len(o.Questions) > 0 { + continue + } + call, cancel := context.WithTimeout(ctx, 6*time.Second) + _ = c.schedules.Dispatch(call, j.ID, q.AccountFingerprint, true, time.Now(), func(ctx context.Context, j schedule.Job) error { + latest := c.scheduleSnapshot() + if latest.AccountFingerprint != q.AccountFingerprint || !schedule.QuotaReady(latest, time.Now()) || !schedule.Covers(j, latest) || c.scheduleBlocked(j.Session) { + return schedule.ErrDeferred + } + return c.prompts.SendSessionPrompt(ctx, o.token, []string{j.Text}) + }) + cancel() + } + c.publishSchedules() +} diff --git a/internal/web/schedules_test.go b/internal/web/schedules_test.go new file mode 100644 index 0000000..db1cc9f --- /dev/null +++ b/internal/web/schedules_test.go @@ -0,0 +1,234 @@ +package web + +import ( + "context" + "encoding/json" + "github.com/merefield/codexometer/internal/codex" + "github.com/merefield/codexometer/internal/schedule" + "testing" + "time" +) + +func TestScheduleWaitingReasons(t *testing.T) { + s, f, token := controlServer(t) + s.store.contexts["parent"] = codex.SessionContext{Kind: codex.SessionContextReply} + f.prompt = codex.SessionPromptOffer{ThreadID: "parent", Token: "idle"} + s.store.scheduleQuota = codex.Snapshot{AccountFingerprint: "a", FetchedAt: time.Now(), RateLimits: codex.RateLimitSnapshot{Primary: &codex.Window{UsedPercent: 100}}} + if err := s.control.schedules.Save(schedule.Job{Session: "parent", Text: "Later", Trigger: "quota"}, "a", time.Now()); err != nil { + t.Fatal(err) + } + check := func(want string, ready bool) { + t.Helper() + w := actionCall(s, token, "schedules", actionRequest{Session: "parent"}) + var jobs []struct { + WaitingReason string `json:"waitingReason"` + CanSend bool `json:"canSend"` + } + if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &jobs) != nil || len(jobs) != 1 || jobs[0].WaitingReason != want || jobs[0].CanSend != ready { + t.Fatalf("unexpected readiness: %s", w.Body) + } + } + check("waiting for quota", false) + s.store.scheduleQuota.FetchedAt = time.Now().Add(-time.Hour) + check("waiting for fresh quota", false) + s.store.scheduleQuota.FetchedAt = time.Now() + s.store.scheduleQuota.RateLimits.Primary.UsedPercent = 0 + check("ready; awaiting dispatch", true) +} + +func TestScheduleEditCommitRejectsChangedJob(t *testing.T) { + for _, change := range []string{"none", "replace", "cancel", "send", "unbound"} { + t.Run(change, func(t *testing.T) { + s, f, token := controlServer(t) + s.store.contexts["parent"] = codex.SessionContext{Kind: codex.SessionContextReply} + f.prompt = codex.SessionPromptOffer{ThreadID: "parent", Token: "idle"} + s.store.scheduleQuota = codex.Snapshot{AccountFingerprint: "a", FetchedAt: time.Now(), RateLimits: codex.RateLimitSnapshot{Primary: &codex.Window{UsedPercent: 0}}} + j := schedule.Job{Session: "parent", Text: "original", Trigger: "quota"} + if err := s.control.schedules.Save(j, "a", time.Now()); err != nil { + t.Fatal(err) + } + id := s.control.schedules.List("parent")[0].ID + editID := id + if change == "unbound" { + editID = "" + } + o := getOffer(t, s, token) + r := prepareAction(t, s, token, actionRequest{Session: "parent", Offer: o.ID, EditID: editID, Answers: []string{"edited"}, Schedule: &scheduleRule{Trigger: "quota"}}) + switch change { + case "replace": + if err := s.control.schedules.Save(j, "a", time.Now()); err != nil { + t.Fatal(err) + } + case "cancel": + if err := s.control.schedules.Cancel("parent", id); err != nil { + t.Fatal(err) + } + case "send": + if err := s.control.schedules.Dispatch(context.Background(), id, "a", true, time.Now(), func(context.Context, schedule.Job) error { return nil }); err != nil { + t.Fatal(err) + } + } + want := 409 + if change == "none" { + want = 200 + } + if w := actionCall(s, token, "commit", r); w.Code != want { + t.Fatalf("commit = %d: %s", w.Code, w.Body) + } + if f.calls != 0 { + t.Fatal("editing sent a prompt") + } + for _, got := range s.control.schedules.List("parent") { + if change != "none" && got.Text == "edited" { + t.Fatal("stale edit saved") + } + } + }) + } +} + +func TestSchedulePrepareCommitDispatchAndCancel(t *testing.T) { + s, f, token := controlServer(t) + s.store.contexts["parent"] = codex.SessionContext{Kind: codex.SessionContextReply} + f.prompt = codex.SessionPromptOffer{ThreadID: "parent", Token: "idle"} + s.store.scheduleQuota = codex.Snapshot{AccountFingerprint: "a", FetchedAt: time.Now(), RateLimits: codex.RateLimitSnapshot{Primary: &codex.Window{UsedPercent: 100}}} + o := getOffer(t, s, token) + r := prepareAction(t, s, token, actionRequest{Session: "parent", Offer: o.ID, Answers: []string{"later"}, Schedule: &scheduleRule{Trigger: "quota"}}) + if len(s.control.schedules.List("")) != 0 { + t.Fatal("prepare saved") + } + if w := actionCall(s, token, "commit", r); w.Code != 200 { + t.Fatalf("commit: %s", w.Body) + } + if f.calls != 0 { + t.Fatal("schedule sent immediately") + } + s.control.dispatchSchedules(context.Background()) + if f.calls != 0 { + t.Fatal("sent while exhausted") + } + s.store.scheduleQuota.RateLimits.Primary.UsedPercent = 0 + s.control.dispatchSchedules(context.Background()) + s.control.dispatchSchedules(context.Background()) + if f.calls != 1 || len(f.answers) != 1 || f.answers[0] != "later" { + t.Fatal("wrong dispatch", f.calls, f.answers) + } + jobs := s.control.schedules.List("parent") + if jobs[0].Status != "sent" { + t.Fatal(jobs) + } + if w := actionCall(s, token, "schedules", actionRequest{Session: "parent", CancelID: jobs[0].ID}); w.Code != 200 { + t.Fatal(w.Body) + } + if len(s.control.schedules.List("")) != 0 { + t.Fatal("not cancelled") + } +} + +func TestScheduleCannotAttachToApproval(t *testing.T) { + s, _, token := controlServer(t) + o := getOffer(t, s, token) + choice := 0 + w := actionCall(s, token, "prepare", actionRequest{Session: "parent", Offer: o.ID, Choice: &choice, Schedule: &scheduleRule{Trigger: "quota"}}) + if w.Code != 409 { + t.Fatal("scheduled approval", w.Code) + } +} + +func TestScheduleFreshnessAndAuthentication(t *testing.T) { + s, f, token := controlServer(t) + s.store.contexts["parent"] = codex.SessionContext{Kind: codex.SessionContextReply} + f.prompt = codex.SessionPromptOffer{ThreadID: "parent", Token: "idle"} + o := getOffer(t, s, token) + r := prepareAction(t, s, token, actionRequest{Session: "parent", Offer: o.ID, Answers: []string{"later"}, Schedule: &scheduleRule{Trigger: "quota"}}) + if w := actionCall(s, token, "commit", r); w.Code != 409 { + t.Fatal("accepted missing quota", w.Code) + } + if w := actionCall(s, "", "schedules", actionRequest{Session: "parent"}); w.Code == 200 { + t.Fatal("unauthenticated schedule access") + } +} + +func TestScheduleSendNowConfirmationAndReplacement(t *testing.T) { + s, f, token := controlServer(t) + s.store.contexts["parent"] = codex.SessionContext{Kind: codex.SessionContextReply} + f.prompt = codex.SessionPromptOffer{ThreadID: "parent", Token: "idle"} + s.store.scheduleQuota = codex.Snapshot{AccountFingerprint: "a", FetchedAt: time.Now(), RateLimits: codex.RateLimitSnapshot{Primary: &codex.Window{UsedPercent: 0}}} + job := schedule.Job{Session: "parent", Text: "exact saved text", Trigger: "at", At: time.Now().Add(time.Hour)} + if err := s.control.schedules.Save(job, "a", time.Now()); err != nil { + t.Fatal(err) + } + o := getOffer(t, s, token) + id := s.control.schedules.List("parent")[0].ID + if w := actionCall(s, token, "prepare", actionRequest{Session: "parent", Offer: o.ID, Answers: []string{"bypass pending job"}}); w.Code != 409 { + t.Fatal("ordinary composer bypassed pending trigger") + } + r := prepareAction(t, s, token, actionRequest{Session: "parent", Offer: o.ID, SendID: id}) + if f.calls != 0 { + t.Fatal("sent on preparation") + } + if err := s.control.schedules.Save(job, "a", time.Now()); err != nil { + t.Fatal(err) + } + if w := actionCall(s, token, "commit", r); w.Code != 409 { + t.Fatal("replaced trigger confirmation accepted", w.Code) + } + id = s.control.schedules.List("parent")[0].ID + r = prepareAction(t, s, token, actionRequest{Session: "parent", Offer: o.ID, SendID: id}) + if w := actionCall(s, token, "commit", r); w.Code != 200 { + t.Fatal(w.Body) + } + s.control.dispatchSchedules(context.Background()) + if f.calls != 1 || f.answers[0] != "exact saved text" { + t.Fatal(f.calls, f.answers) + } +} + +func TestScheduleSendNowRejectsExhaustedQuota(t *testing.T) { + s, f, token := controlServer(t) + s.store.contexts["parent"] = codex.SessionContext{Kind: codex.SessionContextReply} + f.prompt = codex.SessionPromptOffer{ThreadID: "parent", Token: "idle"} + s.store.scheduleQuota = codex.Snapshot{AccountFingerprint: "a", FetchedAt: time.Now(), RateLimits: codex.RateLimitSnapshot{Primary: &codex.Window{UsedPercent: 100}}} + if err := s.control.schedules.Save(schedule.Job{Session: "parent", Text: "later", Trigger: "quota"}, "a", time.Now()); err != nil { + t.Fatal(err) + } + o := getOffer(t, s, token) + if w := actionCall(s, token, "prepare", actionRequest{Session: "parent", Offer: o.ID, SendID: s.control.schedules.List("parent")[0].ID}); w.Code != 409 { + t.Fatal("quota bypassed", w.Code) + } +} + +type preflightPromptSource struct { + *actionSource + beforeReturn func() +} + +func (f *preflightPromptSource) SessionPrompt(thread string) codex.SessionPromptOffer { + o := f.actionSource.SessionPrompt(thread) + if f.beforeReturn != nil { + f.beforeReturn() + } + return o +} + +func TestSchedulerDefersWhenQuotaChangesBeforeSend(t *testing.T) { + s, f, _ := controlServer(t) + s.store.contexts["parent"] = codex.SessionContext{Kind: codex.SessionContextReply} + f.prompt = codex.SessionPromptOffer{ThreadID: "parent", Token: "idle"} + s.store.scheduleQuota = codex.Snapshot{AccountFingerprint: "a", FetchedAt: time.Now(), RateLimits: codex.RateLimitSnapshot{Primary: &codex.Window{UsedPercent: 0}}} + if err := s.control.schedules.Save(schedule.Job{Session: "parent", Text: "later", Trigger: "quota"}, "a", time.Now()); err != nil { + t.Fatal(err) + } + p := &preflightPromptSource{actionSource: f, beforeReturn: func() { s.store.scheduleQuota.RateLimits.Primary.UsedPercent = 100 }} + s.control.prompts = p + s.control.dispatchSchedules(context.Background()) + if f.calls != 0 || s.control.schedules.List("parent")[0].Status != "pending" { + t.Fatal("preflight deferral dispatched or became uncertain") + } + p.beforeReturn = nil + s.store.scheduleQuota.RateLimits.Primary.UsedPercent = 0 + s.control.dispatchSchedules(context.Background()) + if f.calls != 1 { + t.Fatal("deferred request did not recover") + } +} diff --git a/internal/web/server.go b/internal/web/server.go index 4d6bb13..6e1cb1b 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -62,6 +62,8 @@ func Run(ctx context.Context, source Source, refresh time.Duration, port int, ou ctx, cancel := context.WithCancel(ctx) defer cancel() if s.control != nil { + waitSchedules := s.control.collectSchedules(ctx) + defer func() { cancel(); waitSchedules() }() waitProfiles := s.control.collectProfiles(ctx, refresh) defer func() { cancel(); waitProfiles() }() } @@ -101,7 +103,7 @@ func (s *server) handler() http.Handler { }))) mux.Handle("GET /api/events", s.authorize(http.HandlerFunc(s.events))) if s.control != nil { - for _, action := range []string{"offer", "prepare", "commit"} { + for _, action := range []string{"offer", "prepare", "commit", "schedules"} { mux.Handle("POST /api/control/"+action, s.authorize(http.HandlerFunc(s.control.handle(action, "http://"+s.host)))) } } diff --git a/internal/web/state.go b/internal/web/state.go index 2d45e57..59573aa 100644 --- a/internal/web/state.go +++ b/internal/web/state.go @@ -21,6 +21,9 @@ type Source interface { // Deliberately project source types: never serialize approval/input capabilities, // account fingerprints, arbitrary errors, or authentication objects to browsers. type session struct { + CurrentTask string `json:"currentTask,omitempty"` + LatestGuidance string `json:"latestGuidance,omitempty"` + Streaming bool `json:"streaming,omitempty"` Name string `json:"name,omitempty"` ID string `json:"id"` Directory string `json:"directory"` @@ -114,6 +117,7 @@ type quotaThreshold struct { } type state struct { + Triggers []triggerSummary `json:"triggers,omitempty"` Profiles []profileReview `json:"profiles,omitempty"` Thresholds []quotaThreshold `json:"thresholds,omitempty"` ProfileError bool `json:"profileError,omitempty"` @@ -136,6 +140,7 @@ type store struct { profileWindow string profileRevision uint64 quotaSnapshot codex.Snapshot + scheduleQuota codex.Snapshot contexts map[string]codex.SessionContext // Private: never published in state/SSE. directories map[string]string mu sync.Mutex @@ -207,6 +212,7 @@ func (s *store) quota(q codex.Snapshot, err error, now time.Time) { gap := s.state.QuotaError s.state.QuotaError = err != nil if err == nil { + s.scheduleQuota = q s.quotaSnapshot = codex.CaptureQuotaProfile(q) previous := s.state.Meters if s.account != q.AccountFingerprint { @@ -328,6 +334,7 @@ func (s *store) live(l codex.LiveUsageSnapshot, err error, now time.Time) { Command: row.Context.CommandDetails.Command, Source: row.Context.Source, Activity: row.LastActivity, WorkingCommand: activity.Command, CommandStatus: activity.CommandStatus, ApprovalContext: approvalContext, + CurrentTask: codex.SanitizeSessionContext(row.Context.CurrentTask), LatestGuidance: codex.SanitizeSessionContext(row.Context.LatestGuidance), Streaming: row.Context.Streaming, RunningCommands: activity.RunningCommands, RunningLimited: activity.RunningLimited, Samples: s.samples[row.ID], }) diff --git a/intro-post.md b/intro-post.md index 9d2a357..b0fe6fb 100644 --- a/intro-post.md +++ b/intro-post.md @@ -87,6 +87,8 @@ Subtle animated dots in inline and full-screen detail indicate observed work, di On a shared-server session ready for input, the selected full-width detail row also offers a follow-up composer when there is room for the complete context. Click or press Enter to write, Enter to send, and Escape to leave the editor. Smaller panels and structured questions keep full-screen detail as the fallback. +**Schedule the next move:** experimental full-detail scheduling (Ctrl+S, also in writable web mode) offers a delay, a calendar date/time, or confirmed quota recovery. A clickable **TRIGGER SET** pill and session label open the scheduler. While queued, the saved prompt and timing replace the ordinary composer: edit with Ctrl+S, delete with Ctrl+D, or request Send now with Ctrl+N and explicitly confirm with Ctrl+Y. The form is fully keyboard navigable; buttons work too. Requests follow session settings and wait for an eligible idle session with quota available. Keep Codexometer running: the queue is memory-only, closing the process cancels it, and uncertain sends are never automatically retried. It does not redeem reset credits. + ## Quota estimates, resets, and session attention API-equivalent figures are workload-dependent estimates, not your subscription’s cash value or a statement of OpenAI’s private quota formula. They need clean observed quota movement to learn, show uncertainty, and restart learning when Codexometer is relaunched. Pricing uses published input, cached-input, and output rates where the model and usage are known; missing data is not treated as free. diff --git a/web/package-lock.json b/web/package-lock.json index 1a9526b..b5a6241 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1042,9 +1042,9 @@ } }, "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", "dev": true, "license": "BSD-3-Clause", "engines": { diff --git a/web/src/SessionActions.svelte b/web/src/SessionActions.svelte index 9dd0a82..ca42f2a 100644 --- a/web/src/SessionActions.svelte +++ b/web/src/SessionActions.svelte @@ -1,17 +1,19 @@ -{#if !followUpBlocked} +{#if !review && offer?.kind !== 'approval' && !offer?.questions?.length && !['APPROVAL NEEDED', 'INPUT NEEDED'].includes(status || '')} + {#each jobs.filter((j) => j.status !== 'sent') as job (job.id)} +
        +

        + {job.status === 'uncertain' + ? 'CHECK TRIGGER — outcome uncertain; not retried' + : 'SCHEDULED FOLLOW-UP'} +

        +

        TARGET // {job.session}

        +

        + {job.trigger === 'quota' + ? 'After confirmed quota recovery, when idle' + : `Not before ${localTime(job.at)}`} +

        +

        {waitingText(job)}

        +
        {job.text}
        + {#if notice}

        {notice}

        {/if} +

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

        + + + {#if confirming && sendNowID === job.id} +

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

        + + + {:else}{/if} +
        + {/each} +{/if} + +{#if !followUpBlocked && (!pending || editing || review || offer?.kind !== 'prompt' || offer.questions?.length)}

        {review === 'profile' @@ -284,7 +535,9 @@ {offer.kind === 'approval' || offer.kind === 'profile' ? 'Choose a decision' - : 'Reply to this session'} {#if offer.kind === 'approval' || offer.kind === 'profile'} {#each offer.choices || [] as option, index} @@ -304,6 +557,53 @@ {/each} {:else} + {#if !offer.questions?.length} + + {#if timing === 'delay'}{/if} + {#if timing === 'at'}{/if} + {#if timing !== 'now'} +

        + Will send: {timing === 'quota' + ? 'when quota is available and this session is idle' + : Number.isFinite(targetTime) + ? localTime(targetTime) + : 'choose a date and time'} +

        + {#if timing !== 'quota'}

        + Not before this time; when quota and idle allow. +

        {/if} +

        + Timezone: {Intl.DateTimeFormat().resolvedOptions().timeZone}. + One pending request per session; saving replaces it. Memory + only; Codexometer must stay running. Uses session model, + reasoning and speed. +

        {/if} + {#if editing}

        + The existing trigger remains active until saved or deleted. If + it changes or sends while you edit, saving is blocked. +

        {/if} + {/if} {#each questions as question, index}