commit b7fea83ed6ceeb789ff219aa91826c0e388805c2 Author: Alex Blank Date: Mon Jul 27 14:51:45 2026 +0200 feat(bridge): establish local pi status bridge Deliver the initial local bridge, Noctalia v4/v5 adapters, desktop client, service unit, tests, and implementation documentation for persistent Pi status and control. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..926e0a2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +# Agent artifacts +/.pi-subagents/ + +# Dependencies +/node_modules/ + +# Runtime files +*.log +*.pid + +# Local environment configuration +.env +.env.* +!.env.example + +# OS and IDE metadata +.DS_Store +.idea/ diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 0000000..8f3b665 --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,101 @@ +# Pi Status Bridge — Design + +## Reason for existence + +Provide a persistent, local Pi coding-agent experience through a compact status-bar UI without duplicating Pi’s terminal TUI or weakening tool-owned safety policy. + +## Goals + +- Keep one Pi RPC agent per project/worktree, plus a home-directory agent started with the bridge. +- Open a chat-like popover by shortcut; accept prompts, steering messages, follow-ups, and structured extension questions. +- Present compact status in Noctalia: state glyph/color, active project, and attention badge. +- Make the bridge reusable by future desktop/status-bar adapters. +- Resume sessions after bounded automatic recovery. + +## Non-goals + +- Terminal emulation or embedding Pi’s terminal TUI. +- Rendering rich TUI-only extension overlays. +- Inventing approval policy or interpreting tool permissions. +- A network service, remote control, or adapters beyond Noctalia in v1. + +## Architecture + +```text +Noctalia bar + popover ─┐ + ├─ local client helper ─ Unix socket ─ Pi Status Bridge +future host adapter ────┘ │ + ├─ Agent registry + ├─ session/recovery supervisor + └─ pi --mode rpc (one child/worktree) +``` + +### Bridge + +The bridge is the only component that starts, stops, and restarts Pi. It owns: + +- Agent registry keyed by canonical project/worktree path. +- A long-lived `pi --mode rpc` child per active worktree. +- Translation of Pi JSONL events into a stable local event model. +- Session resume after child restart, bounded exponential backoff, and terminal error state after exhaustion. +- A per-user Unix-domain socket under `$XDG_RUNTIME_DIR` with restrictive permissions. +- A lock/PID file with stale-owner recovery. + +It exposes only local request/response and subscription operations: enumerate/select agents, send `prompt`/`steer`/`follow_up`, abort, read state/transcript, proxy model/thinking commands, and answer extension UI requests. + +### Noctalia adapter + +The adapter is presentation only: + +- Bar: glyph/color, selected project name, attention badge. +- Shortcut: choose the focused worktree when unambiguous; otherwise open a selector defaulted to home. +- Popover: transcript, composer, tool activity, queue state, extension UI requests, agent/session manager, and Pi-proxied model/thinking controls. +- Escape hides the popover only; it never aborts Pi. + +The popover always permits explicit project/worktree switching. + +## Agent lifecycle + +1. Bridge starts and acquires the lock. +2. It creates/resumes the home agent. +3. A focused worktree or explicit selection creates/resumes that worktree’s agent. +4. On unexpected child exit, the bridge retries with bounded exponential backoff and resumes the saved Pi session. +5. After retry exhaustion, it publishes an error state and exposes explicit restart/retry controls. + +## Interaction contract + +| State | Bar | Popover action | +| --- | --- | --- | +| Idle | neutral glyph | send prompt, browse transcript | +| Streaming/tool active | working glyph | stream response/tool progress, steer or abort | +| Queued input | attention badge | inspect queue or send follow-up | +| Extension question/confirmation | attention badge | render request and return response unchanged | +| Failed recovery | error glyph | inspect failure and retry/restart | + +## Policy and configuration boundaries + +- Extensions/tools decide whether approval is required. +- The bridge forwards only RPC-compatible structured extension requests and responses. +- Pi chooses model and thinking defaults and remains the source of truth. +- The popover may display current Pi values and forward supported changes; it stores no competing defaults or persistence policy. + +## Security invariants + +- **Never** listen on TCP for v1. +- **Never** allow another user to attach to the socket. +- **Never** map a bridge request to another worktree silently. +- **Never** treat a bridge/UI action as permission to bypass extension-owned approvals. + +## Key risks + +- Focused-window worktree inference can fail; explicit selection is mandatory. +- TUI-only extension overlays cannot cross the RPC boundary. +- The local client helper must retain same-user-only access. +- Named-session controls must clearly show the selected worktree before acting. + +## Verification + +```sh +# After implementation: required architecture artifacts exist. +test -f DESIGN.md && test -f IMPLEMENTATION_PLAN.md && test -f TEST_PLAN.md +``` diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md new file mode 100644 index 0000000..5c06160 --- /dev/null +++ b/IMPLEMENTATION_PLAN.md @@ -0,0 +1,100 @@ +# Pi Status Bridge — Implementation Plan + +## Reason for existence + +Turn the approved design into small, verifiable slices while preserving Pi as the source of truth for tool policy and model configuration. + +## Milestone 1 — Bridge foundation + +1. Create the bridge package and an explicit local protocol schema. + - Define agent ID, worktree path, state, event, extension-request, and command envelopes. + - Define socket path, file modes, lock/PID format, and stale-owner algorithm. + - Verify: schema tests reject malformed messages and cross-worktree requests. + +2. Implement single-instance startup and Unix-socket serving. + - Acquire lock atomically; detect a live owner; recover only a proven-stale lock. + - Create the socket in `$XDG_RUNTIME_DIR` with owner-only permissions. + - Verify: a second bridge refuses startup; a different user cannot connect. + +3. Implement the Pi RPC child adapter. + - Spawn `pi --mode rpc` with an explicit worktree cwd and session location. + - Correlate command IDs, parse JSONL, normalize state/event payloads, and preserve transcript cursors. + - Verify: a fake Pi RPC fixture exercises prompt, streaming, tool events, queue updates, and extension UI requests. + +## Milestone 2 — Agent registry and recovery + +1. Implement worktree-keyed agent management. + - Canonicalize paths before registry lookup. + - Create/resume the home agent on bridge startup. + - Create/resume worktree agents only when selected or inferred. + - Verify: separate worktrees never share agent IDs, transcript entries, or outgoing prompts. + +2. Implement session persistence and recovery. + - Store the Pi session reference per agent. + - Restart unexpected exits with bounded exponential backoff. + - Resume the prior Pi session; publish failed-recovery state after limits are exhausted. + - Verify: injected crashes resume the expected session and stop retrying at the configured bound. + +3. Proxy Pi-owned controls without bridge defaults. + - Read current model/thinking state from Pi. + - Forward supported user changes directly to Pi and refresh displayed state from Pi’s response/events. + - Verify: bridge restarts do not reapply an independent model or thinking value. + +## Milestone 3 — Adapter-facing API and local helper + +1. Implement adapter operations. + - Agent/session listing and explicit selection. + - State/transcript subscription and replay after reconnect. + - Prompt, steer, follow-up, abort, and extension UI response forwarding. + - Verify: a scripted local client can reconnect and catch up without duplicate events. + +2. Build the local client helper. + - Make it the fallback transport for hosts that cannot open Unix sockets. + - Support only the approved bridge protocol; never start a network listener. + - Verify: helper cannot reach a socket owned by another user. + +## Milestone 4 — Noctalia adapter + +1. Implement the bar widget. + - Render state glyph/color, selected project, and attention badge. + - Keep streaming/tool detail out of the bar. + - Verify: fixture states map to the agreed compact bar states. + +2. Implement shortcut and popover behavior. + - Infer focused worktree when unambiguous. + - Otherwise open a selector defaulted to home. + - Support explicit switching, transcript streaming, composer, queue/tool state, extension forms, session manager, and Pi-proxied controls. + - Escape dismisses the popover only. + - Verify: manual checklist passes on Noctalia. + +## Milestone 5 — Hardening and release readiness + + 1. Add observability and safe failure presentation. + - Structured local logs for agent lifecycle, recovery, socket ownership, and protocol errors. + - Redact prompt and tool payload content by default. + - Verify: failure reports identify the agent/worktree without leaking transcript content. + + 2. Run the approved readiness gate. + - Execute automated bridge tests, manual Noctalia QA, cross-user socket rejection check, crash/recovery scenarios, and overnight soak. + - Verify: all exit criteria in `TEST_PLAN.md` pass before daily use. + +## Dependencies + +```text +Foundation → Pi RPC adapter → registry/recovery → adapter API/helper → Noctalia UI → hardening/soak +``` + +## Never rules + +- Never let the bridge decide tool approval policy. +- Never route a prompt to a worktree different from the selected agent. +- Never expose a TCP listener in v1. +- Never persist model or thinking defaults outside Pi. + +## Verification + +```sh +# Plan completeness: all five planned milestones are present. +grep -c '^## Milestone' IMPLEMENTATION_PLAN.md +# Expected: 5 +``` diff --git a/TEST_PLAN.md b/TEST_PLAN.md new file mode 100644 index 0000000..5b1d6bf --- /dev/null +++ b/TEST_PLAN.md @@ -0,0 +1,93 @@ +# Pi Status Bridge — Test Plan / QA Checklist + +## Reason for existence + +Prove that the always-running Pi bridge is correctly scoped, recoverable, and usable through Noctalia before it becomes part of daily work. + +## Automated bridge tests + +### Protocol and event normalization + +- [ ] Reject malformed, oversized, and unknown local protocol messages. +- [ ] Correlate outgoing RPC commands with replies and errors. +- [ ] Normalize agent, turn, message, tool, queue, retry, and extension UI events. +- [ ] Replay only entries after a reconnect cursor; never duplicate transcript events. +- [ ] Preserve extension request IDs and forward the exact selected/confirmed/input response. + +### Agent and worktree isolation + +- [ ] Canonical paths map to a single expected agent ID. +- [ ] Different worktrees receive distinct Pi cwd/session references. +- [ ] A prompt, abort, model change, or extension response never targets another selected worktree. +- [ ] Bridge startup creates/resumes only the home agent. +- [ ] Explicit project selection creates/resumes the requested worktree agent. + +### Pi configuration proxy + +- [ ] Read current model and thinking state from Pi. +- [ ] Forward a user change to Pi using its supported RPC command. +- [ ] Refresh displayed state from Pi, not bridge-held defaults. +- [ ] Restarting the bridge does not overwrite Pi’s model or thinking behavior. + +### Socket and ownership + +- [ ] Socket is under `$XDG_RUNTIME_DIR` with owner-only permissions. +- [ ] A second live bridge instance refuses to acquire ownership. +- [ ] A stale lock is recovered only after liveness verification. +- [ ] Different-user access is rejected. +- [ ] The local helper cannot create or expose a TCP listener. + +### Recovery + +- [ ] Unexpected Pi child exit causes bounded exponential-backoff restart. +- [ ] Recovery resumes the last expected Pi session. +- [ ] Retry exhaustion publishes a failed-recovery state and stops looping. +- [ ] Explicit retry/restart returns the agent to a healthy or clearly failed state. + +## Manual Noctalia checklist + +### Startup and routing + +- [ ] Starting the bridge creates the home agent and shows it in the bar/popover. +- [ ] Shortcut on a known focused worktree opens that worktree’s agent. +- [ ] Missing or ambiguous focus opens the selector with home preselected. +- [ ] Popover project/session manager switches agents explicitly and visibly. +- [ ] Escape closes the popover without aborting streaming work. + +### Status and conversation + +- [ ] Bar shows state glyph/color, active project, and attention badge. +- [ ] Popover streams assistant text and tool activity without blocking the composer. +- [ ] Prompt, steer, follow-up, and abort map to the intended Pi action. +- [ ] Queue changes are visible and do not reorder or lose user messages. +- [ ] Pi model/thinking values display correctly and forwarded changes round-trip from Pi. + +### Extension prompts + +- [ ] Select, confirm, input, and editor requests render as native popover UI. +- [ ] The response reaches the originating extension unchanged. +- [ ] A pending request produces an attention badge while the popover is closed. +- [ ] TUI-only unsupported custom UI is presented as an explicit unsupported/error state, never silently approved. + +## Soak and fault scenarios + +- [ ] Run at least one overnight soak with the home agent and a worktree agent active. +- [ ] Inject at least one Pi child crash during streaming and one while idle. +- [ ] Confirm bounded recovery, resumed sessions, no duplicate events, and no orphan children. +- [ ] Restart Noctalia while the bridge remains active; reconnect without losing pending attention state. +- [ ] Verify no socket, lock, or transcript artifacts are world-readable. + +## Exit criteria + +- All automated cases pass. +- All manual Noctalia checks pass on the target desktop. +- Cross-user socket rejection passes. +- Overnight soak completes without an unbounded restart loop, lost session, incorrect worktree routing, or unhandled extension request. + +## Verification + +```sh +# Checklist remains complete enough for the agreed readiness gate. +grep -c '^\- \[ \]' TEST_PLAN.md +# Expected: at least 30 +``` diff --git a/noctalia-plugin/pi-status-bridge/BarWidget.qml b/noctalia-plugin/pi-status-bridge/BarWidget.qml new file mode 100644 index 0000000..8cbbd51 --- /dev/null +++ b/noctalia-plugin/pi-status-bridge/BarWidget.qml @@ -0,0 +1,55 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell +import qs.Commons +import qs.Widgets + +Item { + id: root + property var pluginApi: null + property ShellScreen screen + property string widgetId: "" + property string section: "" + property int sectionWidgetIndex: -1 + property int sectionWidgetsCount: 0 + + readonly property string screenName: screen?.name ?? "" + readonly property real capsuleHeight: Style.getCapsuleHeightForScreen(screenName) + readonly property string bridgeState: pluginApi?.pluginSettings?.bridgeState ?? "idle" + readonly property string projectLabel: pluginApi?.pluginSettings?.projectLabel ?? "Home" + readonly property int attentionCount: pluginApi?.pluginSettings?.attentionCount ?? 0 + readonly property string glyph: bridgeState === "streaming" ? "◒" : bridgeState === "failed" ? "!" : attentionCount > 0 ? "●" : "●" + readonly property color stateColor: bridgeState === "failed" ? Color.mError : attentionCount > 0 ? Color.mPrimary : Color.mOnSurface + implicitWidth: content.implicitWidth + Style.marginM * 2 + implicitHeight: capsuleHeight + + Rectangle { + id: visualCapsule + anchors.centerIn: parent + width: root.implicitWidth + height: root.implicitHeight + radius: Style.radiusL + color: mouseArea.containsMouse ? Color.mHover : Style.capsuleColor + border.color: Style.capsuleBorderColor + border.width: Style.capsuleBorderWidth + + RowLayout { + id: content + anchors.centerIn: parent + spacing: Style.marginS + Text { text: root.glyph; color: root.stateColor; font.bold: true } + Text { text: root.projectLabel; color: Color.mOnSurface; elide: Text.ElideRight; Layout.maximumWidth: 160 } + Text { visible: root.attentionCount > 0; text: root.attentionCount; color: root.stateColor; font.bold: true } + } + + MouseArea { + id: mouseArea + anchors.fill: parent + hoverEnabled: true + onClicked: { + if (pluginApi) + pluginApi.togglePanel(root.screen, visualCapsule) + } + } + } +} diff --git a/noctalia-plugin/pi-status-bridge/Main.qml b/noctalia-plugin/pi-status-bridge/Main.qml new file mode 100644 index 0000000..212e1cd --- /dev/null +++ b/noctalia-plugin/pi-status-bridge/Main.qml @@ -0,0 +1,25 @@ +import QtQuick +import Quickshell.Io + +Item { + id: root + property var pluginApi: null + + function setBridgeState(state, projectLabel, attentionCount, detail) { + if (!pluginApi) + return + pluginApi.pluginSettings.bridgeState = state + pluginApi.pluginSettings.projectLabel = projectLabel + pluginApi.pluginSettings.attentionCount = attentionCount + pluginApi.pluginSettings.detail = detail + pluginApi.saveSettings() + } + + IpcHandler { + target: "plugin:pi-status-bridge" + + function setBridgeState(state, projectLabel, attentionCount, detail) { + root.setBridgeState(state, projectLabel, attentionCount, detail) + } + } +} diff --git a/noctalia-plugin/pi-status-bridge/Panel.qml b/noctalia-plugin/pi-status-bridge/Panel.qml new file mode 100644 index 0000000..e84278d --- /dev/null +++ b/noctalia-plugin/pi-status-bridge/Panel.qml @@ -0,0 +1,36 @@ +import QtQuick +import QtQuick.Layouts +import qs.Commons +import qs.Widgets + +Item { + id: root + property var pluginApi: null + readonly property var geometryPlaceholder: panelContainer + readonly property bool allowAttach: true + property real contentPreferredWidth: 680 * Style.uiScaleRatio + property real contentPreferredHeight: 420 * Style.uiScaleRatio + readonly property string bridgeState: pluginApi?.pluginSettings?.bridgeState ?? "idle" + readonly property string projectLabel: pluginApi?.pluginSettings?.projectLabel ?? "Home" + readonly property string detail: pluginApi?.pluginSettings?.detail ?? "Bridge not connected" + + anchors.fill: parent + Rectangle { + id: panelContainer + anchors.fill: parent + color: "transparent" + + ColumnLayout { + anchors.fill: parent + anchors.margins: Style.marginL + spacing: Style.marginM + Text { text: "Pi Status Bridge"; color: Color.mOnSurface; font.bold: true; font.pixelSize: 20 } + Text { text: "Project: " + root.projectLabel; color: Color.mOnSurfaceVariant } + Text { text: "State: " + root.bridgeState; color: Color.mOnSurfaceVariant } + Rectangle { Layout.fillWidth: true; height: 1; color: Color.mOutlineVariant } + Text { text: root.detail; color: Color.mOnSurface; wrapMode: Text.Wrap; Layout.fillWidth: true } + Text { text: "Select a project explicitly before sending worktree-scoped actions."; color: Color.mOnSurfaceVariant; wrapMode: Text.Wrap; Layout.fillWidth: true } + Item { Layout.fillHeight: true } + } + } +} diff --git a/noctalia-plugin/pi-status-bridge/README.md b/noctalia-plugin/pi-status-bridge/README.md new file mode 100644 index 0000000..8522fd7 --- /dev/null +++ b/noctalia-plugin/pi-status-bridge/README.md @@ -0,0 +1,14 @@ +# Pi Status Bridge Noctalia plugin + +This Noctalia v4 plugin is deliberately **presentation-only**. It renders +state supplied through its `plugin:pi-status-bridge` IPC handler and opens its +panel with `pluginApi.togglePanel`. + +Use `pi-status-bridge-client` to query the owner-only Unix bridge socket. A +local integration script should translate responses and events into the plugin +IPC handler's `setBridgeState(state, projectLabel, attentionCount, detail)` +call. The plugin never starts Pi, opens TCP, chooses a worktree silently, or +approves extension requests. + +Install/register it through Noctalia's normal plugin workflow, then add its +bar widget in Noctalia settings. diff --git a/noctalia-plugin/pi-status-bridge/manifest.json b/noctalia-plugin/pi-status-bridge/manifest.json new file mode 100644 index 0000000..019bc82 --- /dev/null +++ b/noctalia-plugin/pi-status-bridge/manifest.json @@ -0,0 +1,20 @@ +{ + "id": "pi-status-bridge", + "name": "Pi Status Bridge", + "version": "0.1.0", + "minNoctaliaVersion": "4.0.0", + "description": "Presentation-only status bar and panel for a local Pi Status Bridge.", + "entryPoints": { + "main": "Main.qml", + "barWidget": "BarWidget.qml", + "panel": "Panel.qml" + }, + "metadata": { + "defaultSettings": { + "bridgeState": "idle", + "projectLabel": "Home", + "attentionCount": 0, + "detail": "Bridge not connected" + } + } +} diff --git a/noctalia-v5-plugin/pi-status-bridge/bridge.luau b/noctalia-v5-plugin/pi-status-bridge/bridge.luau new file mode 100644 index 0000000..b0e7348 --- /dev/null +++ b/noctalia-v5-plugin/pi-status-bridge/bridge.luau @@ -0,0 +1,89 @@ +local request_in_flight = false + +local function shell_quote(value) + return "'" .. string.gsub(value, "'", "'\\\"'\\\"'") .. "'" +end + +local function ui_binary() + local configured = noctalia.getenv("PI_STATUS_UI_BINARY") + if configured ~= nil and configured ~= "" then return configured end + return (noctalia.getenv("HOME") or "") .. "/projects/pi-status-bridge/ui/src-tauri/target/release/pi-status-ui" +end + +local function socket_path() + local runtime = noctalia.getenv("XDG_RUNTIME_DIR") + if runtime == nil or runtime == "" then return nil end + return runtime .. "/pi-status-bridge/bridge.sock" +end + +local function current_agent_id() + return noctalia.state.get("agent_id") +end + +local function discover_home_agent(socket) + if request_in_flight then return end + request_in_flight = true + noctalia.runAsync("pi-status-bridge-client --socket \"" .. socket .. "\" request '{\"op\":\"list_agents\"}'", function(result) + request_in_flight = false + if result.exitCode ~= 0 then return end + local home = noctalia.getenv("HOME") + for id, worktree in string.gmatch(result.stdout, "\"id\":\"([^\"]+)\",\"worktreePath\":\"([^\"]+)\"") do + if worktree == home then + noctalia.state.set("agent_id", id) + noctalia.state.set("project_path", worktree) + return + end + end + end) +end + +function update() + noctalia.setUpdateInterval(2000) + local agent_id = current_agent_id() + local socket = socket_path() + if socket == nil then + barWidget.setGlyph("alert-triangle") + barWidget.setText("Pi offline") + barWidget.setTooltip("Bridge socket is unavailable") + return + end + if agent_id == nil then + discover_home_agent(socket) + barWidget.setGlyph("terminal-2") + barWidget.setText("Pi home") + barWidget.setTooltip("Starting the default Home agent; click to open Pi Status UI") + return + end + if request_in_flight then return end + + request_in_flight = true + local command = "pi-status-bridge-client --socket \"" .. socket + .. "\" request '{\"op\":\"get_state\",\"agentId\":\"" .. agent_id .. "\"}'" + noctalia.runAsync(command, function(result) + request_in_flight = false + if result.exitCode ~= 0 then + noctalia.state.set("agent_id", nil) + discover_home_agent(socket) + barWidget.setGlyph("terminal-2") + barWidget.setText("Pi reconnecting") + barWidget.setTooltip("Refreshing the Home Pi agent") + return + end + local streaming = string.find(result.stdout, "\"isStreaming\":true", 1, true) ~= nil + barWidget.setGlyph(streaming and "terminal-2" or "terminal") + barWidget.setText(streaming and "Pi working" or "Pi ready") + barWidget.setTooltip(noctalia.state.get("project_path") or "Selected Pi folder") + end) +end + +function onClick() + local command = "setsid -f env TMPDIR=/tmp " .. shell_quote(ui_binary()) + .. " --show >/tmp/pi-status-ui.log 2>&1" + noctalia.runAsync(command, function(result) + if result.exitCode ~= 0 then + barWidget.setGlyph("alert-triangle") + barWidget.setText("Pi UI unavailable") + barWidget.setTooltip("Build Pi Status UI or set PI_STATUS_UI_BINARY") + end + end) +end diff --git a/noctalia-v5-plugin/pi-status-bridge/picker.luau b/noctalia-v5-plugin/pi-status-bridge/picker.luau new file mode 100644 index 0000000..43e203e --- /dev/null +++ b/noctalia-v5-plugin/pi-status-bridge/picker.luau @@ -0,0 +1,360 @@ +local agent_id = nil +local agents = {} +local messages = {} +local folder_path = noctalia.getenv("HOME") or "" +local composer = "" +local extension_input = "" +local pending_extension = nil +local status = "Loading Pi agents…" +local request_in_flight = false +local stream_started = false +local view = "output" +local command_suggestions = {} +local models = {} +local model_options = {} +local thinking_options = { "off", "minimal", "low", "medium", "high", "xhigh", "max" } +local thinking_level = "medium" +local session_summary = "Session loading…" + +local function socket_path() + local runtime = noctalia.getenv("XDG_RUNTIME_DIR") + if runtime == nil or runtime == "" then return nil end + return runtime .. "/pi-status-bridge/bridge.sock" +end + +local function shell_quote(value) + return "'" .. string.gsub(value, "'", "'\"'\"'") .. "'" +end + +local function request(op, agent, payload, callback) + local socket = socket_path() + if socket == nil then + status = "Bridge socket is unavailable." + render_panel() + return + end + local body = { op = op } + if agent ~= nil then body.agentId = agent end + if payload ~= nil then body.payload = payload end + local encoded, err = noctalia.json.encode(body) + if encoded == nil then + status = "Could not encode bridge request: " .. tostring(err) + render_panel() + return + end + local command = "pi-status-bridge-client --socket " .. shell_quote(socket) .. " request " .. shell_quote(encoded) + noctalia.runAsync(command, function(result) + if result.exitCode ~= 0 then + status = "Bridge request failed: " .. noctalia.string.trim(result.stderr) + render_panel() + return + end + local decoded, decode_error = noctalia.json.decode(result.stdout) + if decoded == nil then + status = "Invalid bridge response: " .. tostring(decode_error) + render_panel() + return + end + callback(decoded) + end) +end + +local function message_text(message) + if message == nil then return "" end + if type(message.content) == "string" then return message.content end + if type(message.content) ~= "table" then return "" end + local parts = {} + for _, block in ipairs(message.content) do + if block.type == "text" and block.text ~= nil then table.insert(parts, block.text) end + if block.type == "thinking" and block.thinking ~= nil then table.insert(parts, "Thinking: " .. block.thinking) end + if block.type == "toolCall" and block.name ~= nil then table.insert(parts, "Tool: " .. block.name) end + end + return table.concat(parts, "\n") +end + +local function choose_home_agent() + local home = noctalia.getenv("HOME") + for _, agent in ipairs(agents) do + if agent.worktreePath == home then return agent.id end + end + return agents[1] and agents[1].id or nil +end + +local function known_agent(id) + for _, agent in ipairs(agents) do + if agent.id == id then return true end + end + return false +end + +local function refresh_agents() + request("list_agents", nil, nil, function(result) + agents = result.agents or {} + local remembered_id = agent_id or noctalia.state.get("agent_id") + agent_id = known_agent(remembered_id) and remembered_id or choose_home_agent() + if agent_id ~= nil then noctalia.state.set("agent_id", agent_id) end + status = agent_id and "Pi ready" or "Add a folder to start Pi" + render_panel() + if agent_id ~= nil then + refresh_transcript() + refresh_commands() + refresh_controls() + end + end) +end + +function refresh_controls() + if agent_id == nil then return end + request("get_state", agent_id, nil, function(result) + local state = result.data or {} + thinking_level = state.thinkingLevel or thinking_level + session_summary = (state.sessionName or state.sessionId or "Session") + .. " • " .. tostring(state.messageCount or 0) .. " messages" + .. " • " .. tostring(state.pendingMessageCount or 0) .. " queued" + status = state.isStreaming and "Pi working" or "Pi ready" + request("get_available_models", agent_id, nil, function(models_result) + models = (models_result.data and models_result.data.models) or {} + model_options = {} + for _, model in ipairs(models) do + table.insert(model_options, (model.provider or "") .. "/" .. (model.id or model.name or "model")) + end + render_panel() + end) + end) +end + +function refresh_commands() + if agent_id == nil then return end + request("get_commands", agent_id, nil, function(result) + command_suggestions = {} + for _, command in ipairs((result.data and result.data.commands) or {}) do + if command.name ~= nil then table.insert(command_suggestions, "/" .. command.name) end + end + render_panel() + end) +end + +function refresh_transcript() + if agent_id == nil or request_in_flight then return end + request_in_flight = true + request("get_transcript", agent_id, nil, function(result) + request_in_flight = false + messages = result.data and result.data.messages or {} + render_panel() + end) +end + +local function start_stream() + if stream_started or agent_id == nil then return end + local socket = socket_path() + if socket == nil then return end + stream_started = true + local command = "pi-status-bridge-client --socket " .. shell_quote(socket) .. " subscribe --agent " .. shell_quote(agent_id) + noctalia.runStream(command, function(line) + local event = noctalia.json.decode(line) + if event == nil then return end + if event.type == "extension_ui_request" then pending_extension = event.data and event.data.event end + if event.type == "agent_state" then status = (event.data and event.data.state) or "Pi state changed" end + if event.type == "queue" then status = "Pi queue updated" end + if event.type == "stream" and event.data and event.data.event and event.data.event.type == "tool_execution_start" then + status = "Tool: " .. tostring(event.data.event.toolName or "running") + end + if event.type == "message_end" or event.type == "agent_state" then refresh_transcript() end + render_panel() + end) +end + +local function agent_label(agent) + return agent.worktreePath .. " — " .. agent.state +end + +function render_panel() + local selected_path = noctalia.state.get("project_path") or "Home" + local children = { + ui.row({ gap = 8, justify = "space_between" }, { + ui.column({ gap = 2 }, { + ui.label({ text = "Pi", fontSize = 22, fontWeight = "bold" }), + ui.label({ text = selected_path .. " — " .. status }) + }), + ui.button({ text = view == "output" and "Settings" or "Output", glyph = "settings", variant = "ghost", onClick = "onToggleSettings" }) + }) + } + + if view == "settings" then + table.insert(children, ui.label({ text = "Folders and agents", fontWeight = "bold" })) + local choices = {} + for _, agent in ipairs(agents) do table.insert(choices, agent_label(agent)) end + if #choices > 0 then table.insert(children, ui.select({ options = choices, onChange = "onSettingsAgentChange" })) end + table.insert(children, ui.input({ value = folder_path, placeholder = "/absolute/path/to/project", onChange = "onFolderChanged", onSubmit = "onAddFolder" })) + table.insert(children, ui.button({ text = "Add folder", glyph = "folder-plus", variant = "primary", onClick = "onAddFolder" })) + table.insert(children, ui.label({ text = "Adding a folder explicitly starts or resumes its own Pi instance." })) + table.insert(children, ui.label({ text = session_summary, maxLines = 2 })) + table.insert(children, ui.label({ text = "Pi controls", fontWeight = "bold" })) + if #model_options > 0 then table.insert(children, ui.select({ options = model_options, onChange = "onModelChange" })) end + table.insert(children, ui.select({ options = thinking_options, onChange = "onThinkingChange" })) + table.insert(children, ui.row({ gap = 8 }, { + ui.button({ text = "Retry", onClick = "onRetry" }), + ui.button({ text = "Restart Pi", variant = "destructive", onClick = "onRestart" }) + })) + else + table.insert(children, ui.label({ text = "Output", fontWeight = "bold" })) + local transcript = {} + for _, message in ipairs(messages) do + local text = message_text(message) + if text ~= "" then table.insert(transcript, ui.label({ text = (message.role or "message") .. ": " .. text, maxLines = 12 })) end + end + if #transcript == 0 then table.insert(transcript, ui.label({ text = "No messages yet." })) end + table.insert(children, ui.scroll({ minHeight = 260, maxHeight = 520 }, transcript)) + table.insert(children, ui.input({ value = composer, placeholder = "Message Pi", onChange = "onComposerChanged", onSubmit = "onPrompt" })) + if string.sub(composer, 1, 1) == "/" and #command_suggestions > 0 then + table.insert(children, ui.select({ options = command_suggestions, onChange = "onCommandSuggestion" })) + end + table.insert(children, ui.row({ gap = 8 }, { + ui.button({ text = "Send", variant = "primary", onClick = "onPrompt" }), + ui.button({ text = "Abort", variant = "destructive", onClick = "onAbort" }) + })) + end + + if pending_extension ~= nil then + local method = pending_extension.method or "input" + table.insert(children, ui.label({ text = "Pi extension request: " .. (pending_extension.title or method), fontWeight = "bold" })) + table.insert(children, ui.label({ text = pending_extension.message or "Choose a response." })) + if method == "select" and pending_extension.options ~= nil then + table.insert(children, ui.select({ options = pending_extension.options, onChange = "onExtensionSelect" })) + elseif method == "confirm" then + table.insert(children, ui.row({ gap = 8 }, { + ui.button({ text = "Confirm", variant = "primary", onClick = "onExtensionConfirm" }), + ui.button({ text = "Decline", onClick = "onExtensionDecline" }) + })) + elseif method == "input" or method == "editor" then + table.insert(children, ui.input({ value = extension_input, placeholder = method == "editor" and "Extension editor content" or "Extension response", onChange = "onExtensionInputChanged", onSubmit = "onExtensionSubmit" })) + table.insert(children, ui.button({ text = "Submit", variant = "primary", onClick = "onExtensionSubmit" })) + else + table.insert(children, ui.label({ text = "This extension UI is unsupported in the panel. No response has been approved.", maxLines = 3 })) + end + table.insert(children, ui.button({ text = "Cancel request", variant = "ghost", onClick = "onExtensionCancel" })) + end + panel.render(ui.scroll({ padding = 20, gap = 12 }, children)) +end + +function onOpen() + panel.setWantsSecondTicks(true) + refresh_agents() + render_panel() +end + +function update() + refresh_transcript() +end + +function onFolderChanged(value) folder_path = value end +function onComposerChanged(value) + composer = value + if string.sub(composer, 1, 1) == "/" then render_panel() end +end +function onCommandSuggestion(index) + local suggestion = command_suggestions[tonumber(index) + 1] + if suggestion ~= nil then + composer = suggestion .. " " + render_panel() + end +end +function onExtensionInputChanged(value) extension_input = value end + +function onSettingsAgentChange(index) + local agent = agents[tonumber(index) + 1] + if agent == nil then return end + agent_id = agent.id + noctalia.state.set("agent_id", agent_id) + noctalia.state.set("project_path", agent.worktreePath) + stream_started = false + start_stream() + refresh_transcript() +end + +function onModelChange(index) + local model = models[tonumber(index) + 1] + if model == nil or agent_id == nil then return end + request("set_model", agent_id, { provider = model.provider, modelId = model.id }, function(_) refresh_controls() end) +end + +function onThinkingChange(index) + local level = thinking_options[tonumber(index) + 1] + if level == nil or agent_id == nil then return end + request("set_thinking_level", agent_id, { level = level }, function(_) refresh_controls() end) +end + +function onRetry() + if agent_id ~= nil then request("retry", agent_id, nil, function(_) refresh_agents() end) end +end + +function onRestart() + if agent_id ~= nil then request("restart", agent_id, nil, function(_) refresh_agents() end) end +end + +function onAddFolder() + if string.sub(folder_path, 1, 1) ~= "/" then status = "Enter an absolute folder path."; render_panel(); return end + request("select_agent", nil, { worktreePath = folder_path }, function(result) + agent_id = result.agent and result.agent.id or nil + if agent_id == nil then status = "Bridge did not return an agent."; render_panel(); return end + noctalia.state.set("agent_id", agent_id) + noctalia.state.set("project_path", folder_path) + status = "Pi ready for " .. folder_path + stream_started = false + start_stream() + refresh_agents() + end) +end + +local function submit_composer() + if agent_id == nil then status = "Choose a folder first."; render_panel(); return end + if composer == "" then return end + request("submit_prompt", agent_id, { message = composer }, function(_) + composer = "" + status = "Prompt queued by Pi" + refresh_transcript() + end) +end + +function onPrompt() submit_composer() end +function onAbort() if agent_id ~= nil then request("abort", agent_id, nil, function(_) status = "Aborted"; render_panel() end) end end + +local function send_extension_response(response) + if pending_extension == nil or agent_id == nil then return end + request("extension_response", agent_id, { requestId = pending_extension.id, response = response }, function(_) + pending_extension = nil + extension_input = "" + render_panel() + end) +end + +function onExtensionSubmit() + send_extension_response({ value = extension_input }) +end + +function onExtensionSelect(index) + if pending_extension == nil or pending_extension.options == nil then return end + local option = pending_extension.options[tonumber(index) + 1] + if option ~= nil then send_extension_response({ value = option }) end +end + +function onExtensionConfirm() + send_extension_response({ confirmed = true }) +end + +function onExtensionDecline() + send_extension_response({ confirmed = false }) +end + +function onExtensionCancel() + if pending_extension == nil or agent_id == nil then return end + request("extension_response", agent_id, { requestId = pending_extension.id, response = { cancelled = true } }, function(_) + pending_extension = nil + extension_input = "" + render_panel() + end) +end + +function onClosePicker() + panel.close() +end diff --git a/noctalia-v5-plugin/pi-status-bridge/plugin.toml b/noctalia-v5-plugin/pi-status-bridge/plugin.toml new file mode 100644 index 0000000..a7b6646 --- /dev/null +++ b/noctalia-v5-plugin/pi-status-bridge/plugin.toml @@ -0,0 +1,10 @@ +id = "alex/pi-status-bridge" +name = "Pi Status Bridge" +version = "0.2.0" +plugin_api = 3 +author = "alex" +description = "Compact Pi status and launcher for the standalone Pi Status UI." + +[[widget]] +id = "bridge" +entry = "bridge.luau" diff --git a/package.json b/package.json new file mode 100644 index 0000000..f410ebc --- /dev/null +++ b/package.json @@ -0,0 +1,19 @@ +{ + "name": "pi-status-bridge", + "version": "0.1.0", + "private": true, + "type": "module", + "bin": { + "pi-status-bridge": "src/bridge/cli.js", + "pi-status-bridge-client": "src/client/cli.js", + "pi-status-bridge-noctalia-relay": "src/client/noctalia-relay-cli.js" + }, + "engines": { + "node": ">=20" + }, + "scripts": { + "test": "node --test test/*.test.js && npm run test:ui", + "test:ui": "cargo test --manifest-path ui/src-tauri/Cargo.toml", + "check": "node --check src/protocol/index.js && node --check src/client/local-client.js && node --check src/client/noctalia-relay.js && node --check src/client/noctalia-ipc.js && node --check src/client/noctalia-relay-cli.js && node --check src/bridge/daemon.js && node --check src/bridge/cli.js && node --check src/bridge/agent-registry.js && node --check src/bridge/instance-lock.js && node --check src/bridge/pi-rpc-adapter.js && node --check src/bridge/unix-server.js && node --check src/bridge/runtime.js && node --check src/bridge/recovery-supervisor.js && node --check src/bridge/service.js && npm --prefix ui run build" + } +} diff --git a/specs/FULL_PI_PANEL_SPEC.md b/specs/FULL_PI_PANEL_SPEC.md new file mode 100644 index 0000000..c76b160 --- /dev/null +++ b/specs/FULL_PI_PANEL_SPEC.md @@ -0,0 +1,119 @@ +# Full Pi Panel Specification + +## Reason for existence + +Replace the compact Noctalia status widget with a feature-equivalent local Pi +client panel. The bar remains a compact launcher and status indicator; the +panel is the single interaction surface. + +## User flow + +1. The user session starts one bridge-managed Home agent for `$HOME`. +2. Clicking the bar opens the **Output** view for the selected agent. Home is + selected by default. +3. Output shows transcript/history, live streaming/tool/queue state, extension + requests, and one composer anchored at the bottom. +4. The composer mirrors the confirmed TUI workflow: submit a normal prompt; + when Pi is busy, Pi queues it as a follow-up. The panel never asks the user + to choose prompt versus steer versus follow-up for ordinary submission. +5. **Settings** is the only location for folder management. Adding an absolute + folder explicitly creates/resumes its own agent, persists it, and selects it. +6. The user may switch among remembered agents from Settings. Previously added + folders do not start at login unless selected; Home is always running. + +## Views + +### Output (default) + +- Header: selected folder, agent health, streaming/queue/recovery status. +- Scrollable transcript: user, assistant, tool result, and visible custom + messages. Thinking content follows Pi visibility policy; it is not exposed by + default merely because the panel exists. +- Tool activity: current tool name/progress while active and tool results in + history after completion. +- Composer: one input and one submit action. It accepts normal text, Pi command + syntax, prompt templates, and skills exactly as Pi RPC `prompt` accepts them. +- Suggestions: when composer input begins with `/`, show Pi-provided + `get_commands` entries. Selecting one inserts it; it does not execute until + submitted. +- Extension requests: select, confirm, input, and editor map to native panel + controls and return the exact request ID/response shape. Unsupported custom + TUI UI renders an explicit unsupported state and never approves anything. + +### Settings + +- Agent list: Home plus remembered explicit folders, each with state. +- Folder input: requires an absolute path. Adding it invokes `select_agent`; + bridge canonicalization remains authoritative. +- Session controls: current session metadata, retry/restart, and agent health. +- Pi controls: available models, current model, thinking level, and queue modes + are read from Pi and changed only through Pi proxy operations. No independent + bridge defaults or persistence are allowed. + +## Bridge/API contract + +The panel speaks only through `pi-status-bridge-client` over the owner-only +Unix socket. Required operations are: + +| Purpose | Operation | +| --- | --- | +| agents | `list_agents`, `select_agent` | +| live state | `get_state`, `subscribe` | +| history | `get_transcript` | +| composer | `submit_prompt` | +| recovery | `retry`, `restart` | +| Pi controls | `get_available_models`, `set_model`, `set_thinking_level` | +| suggestions | `get_commands` | +| extension UI | `extension_response` | + +`submit_prompt` is the only default composer operation. The bridge dispatches +it to Pi `prompt` when idle and Pi `follow_up` while streaming; Pi retains +queue semantics. Direct `steer` and `follow_up` may exist only as explicit +advanced/TUI-parity shortcuts after their exact behavior is verified; they are +not ordinary UI buttons. + +## State and persistence + +- Bridge persists the Pi session reference per canonical worktree. +- Noctalia persists the remembered folder list and last selected agent in its + plugin data directory, not the plugin installation directory. +- The bridge starts Home; Noctalia never spawns Pi or owns a socket lock. +- Agent IDs are bridge-issued. The panel never invents or maps IDs to another + worktree. + +## Safety invariants + +- Never expose TCP. +- Never silently select a non-Home folder. +- Never auto-approve extension/tool requests. +- Never replace Pi model, thinking, or queue policy with panel defaults. +- Never treat unsupported custom extension UI as approval. +- Never execute an arbitrary folder path through an unquoted shell command. + +## Acceptance scenarios + +- **Home startup:** After user login, Home appears as a ready/working agent + without opening the panel. +- **Folder add:** Entering an absolute folder in Settings creates/selects its + distinct agent; Home transcript and prompts remain isolated. +- **Normal prompt:** Submitting the one composer while idle sends one Pi prompt. +- **Busy prompt:** Submitting while Pi is active follows Pi's follow-up queue + behavior without an extra panel decision. +- **History:** Reopening Output replays existing transcript, then receives live + updates without duplicate cursor entries. +- **Command suggestion:** Typing `/` presents `get_commands` results; selection + inserts, not executes, a command. +- **Extension safety:** Confirm/select/input/editor responses preserve request + IDs and values; custom UI shows unsupported. +- **Recovery:** Failed-recovery state is visible with explicit retry/restart. + +## Verification + +```sh +npm run check && npm test +/usr/bin/noctalia plugins lint ~/.local/share/noctalia/plugins/pi-status-bridge +/usr/bin/noctalia config validate +``` + +Manual verify: open the panel, select Home, add a folder from Settings, submit a +prompt, close/reopen the panel, and confirm history plus status remain correct. diff --git a/specs/TAURI_CLIENT_ARCHITECTURE.md b/specs/TAURI_CLIENT_ARCHITECTURE.md new file mode 100644 index 0000000..8fff415 --- /dev/null +++ b/specs/TAURI_CLIENT_ARCHITECTURE.md @@ -0,0 +1,140 @@ +# Tauri Pi Client Architecture + +## Purpose + +Replace the Noctalia panel with a testable, status-bar-agnostic desktop Pi +client. Noctalia becomes an optional status indicator and launcher only. + +## Decision + +Use a **Tauri v2 desktop application** with a web frontend and a small Rust +host. The bridge remains the only owner of Pi processes, sessions, recovery, +and the owner-only Unix socket. + +Tauri is appropriate because its frontend talks to a native host through typed +asynchronous IPC, and its WebDriver support gives the client a real E2E test +surface. Reference: and +. + +## Boundaries + +```text +Pi Status Bridge systemd service + └─ Unix JSONL socket: state, events, commands + └─ Tauri Rust host + ├─ validates/serializes bridge requests + ├─ owns subscribe connections and emits UI events + └─ exposes typed Tauri commands + └─ React frontend: desktop UI + +Optional Noctalia compatibility shim + ├─ reads compact state through existing client/bridge protocol + └─ launches or focuses `pi-status-ui --toggle` +``` + +### Security + +- The frontend cannot run shell commands or open sockets. +- Only the Tauri Rust host reads the per-user Unix socket. +- The UI never receives or stores bridge lock tokens. +- No TCP listener is introduced. +- Pi remains authoritative for model, thinking, queues, tool approvals, and + extension request IDs. + +## Tauri host API + +The host maps the existing JSONL protocol into typed commands. It must reject +unknown operations and preserve agent scoping. + +| Command | Bridge operation | Result | +| --- | --- | --- | +| `listAgents` | `list_agents` | current agent summaries | +| `selectWorktree` | `select_agent` | explicitly selected agent | +| `loadAgent` | state, transcript, commands, models | one snapshot | +| `submitPrompt` | `submit_prompt` | Pi-owned prompt/follow-up queueing | +| `abort`, `retry`, `restart` | same | command response | +| `setModel`, `setThinkingLevel` | same | command response | +| `respondToExtension` | `extension_response` | command response | +| `subscribeAgent` | `subscribe` | emits `bridge-event` to this window | + +The Rust host owns reconnection and cursor replay. Its view-model event stream +is normalized to: + +```ts +type BridgeEvent = { + agentId: string; + seq: number; + kind: "state" | "message" | "tool" | "queue" | "extension" | "recovery"; + payload: unknown; +}; +``` + +## Frontend + +### Window behavior + +- One persistent window; close hides it, quit is explicit. +- `--show`, `--hide`, and `--toggle` are routed to the existing instance using + Tauri’s single-instance capability. +- The optional Noctalia widget runs `pi-status-ui --toggle`; it never renders + the primary UI or handles bridge commands. + +### Screens + +1. **Conversation** (default): selected agent, streaming/queue/tool status, + transcript, extension dialog, and one composer. +2. **Agents**: Home plus remembered explicit folders. Selecting a remembered + folder invokes bridge `select_agent`; no folder starts merely because it is + remembered. +3. **Session settings**: model, thinking level, session metadata, retry, and + restart. + +### Composer invariant + +The frontend has one normal submit action. It invokes `submit_prompt`; it does +not decide between prompt, steering, or follow-up. Slash-command selection +inserts text only. + +### Extension invariant + +Render Pi `select`, `confirm`, `input`, and `editor` requests. Present any +other method as unsupported without sending an approval. + +## Persistence + +| Data | Owner | +| --- | --- | +| Pi session/recovery | bridge | +| remembered worktree paths + last selected path | UI app data | +| bridge agent IDs | memory only; invalid after bridge restart | +| compact launcher state | Noctalia only, disposable | + +At startup the UI resolves its stored path through `select_agent`; it never +persists or trusts a bridge agent ID. + +## Implementation sequence + +1. Add `ui/` (React/Vite) and `src-tauri/`; leave current Node bridge intact. +2. Implement Rust JSONL client plus typed command/snapshot contract with unit + tests against a temporary Unix socket server. +3. Implement the conversation store and pure reducer tests for event replay. +4. Build the conversation screen; add WebDriver E2E for initial load, + composer submission, and busy follow-up behavior. +5. Build agent/settings/extension screens and tests. +6. Replace Noctalia panel entry with a small launcher compatibility shim; retain + its compact state indicator. +7. Remove the primary Luau panel only after client E2E and manual desktop checks + pass. + +## Acceptance checks + +- Launching `pi-status-ui --toggle` opens/focuses one window. +- Restarting the bridge recovers to Home or a saved worktree path; it never + issues a command to a stale agent ID. +- A normal submit, busy submit, model change, and extension response route + through typed host commands only. +- A custom extension UI cannot be approved from the client. +- Noctalia can be disabled/uninstalled while the desktop client remains fully + usable. +- Rust host tests, frontend unit tests, WebDriver E2E, bridge tests, and a + manual desktop run pass. diff --git a/specs/planning-context.yaml b/specs/planning-context.yaml new file mode 100644 index 0000000..57c4591 --- /dev/null +++ b/specs/planning-context.yaml @@ -0,0 +1,27 @@ +feature_name: tauri-pi-desktop-client +problem_statement: > + The current Noctalia panel is not reliably interactive or testable, while + users need a feature-equivalent Pi desktop client that works independently + of any status bar. +constraints: + - The bridge remains the only Pi process/session/recovery owner. + - The Tauri host uses only the local Unix bridge client and never opens TCP. + - Noctalia is an optional compact launcher/status compatibility layer. + - Folder selection is explicit and canonicalized by the bridge. + - Pi owns model, thinking, queue behavior, approvals, and extension policy. + - TUI-only custom extension UI must show unsupported and never auto-approve. +out_of_scope: + - Pixel-identical terminal TUI rendering. + - Arbitrary custom TUI extension component rendering. + - Auto-starting every remembered non-Home folder at login. +key_decisions: + - decision: One default composer + rationale: Mirror the confirmed TUI workflow without bridge-owned prompt/steer decisions. + - decision: Tauri owns the primary UI + rationale: Native host IPC plus frontend/unit/E2E testing is more reliable than a declarative status-bar panel. + - decision: Noctalia is a compatibility shim + rationale: It displays compact status and opens/focuses the standalone client without owning UI behavior. + - decision: Persist remembered folders and last selection + rationale: Remove manual ID/folder setup while preserving explicit selection. + - decision: Home agent is always running + rationale: Provide an immediately available default Pi instance. diff --git a/src/bridge/agent-registry.js b/src/bridge/agent-registry.js new file mode 100644 index 0000000..e55fee7 --- /dev/null +++ b/src/bridge/agent-registry.js @@ -0,0 +1,492 @@ +import { createHash, randomUUID } from "node:crypto"; +import { + chmod, + mkdir, + readdir, + readFile, + realpath, + stat, + writeFile, +} from "node:fs/promises"; +import path from "node:path"; +import { startPiRpcAdapter } from "./pi-rpc-adapter.js"; +import { createRecoverySupervisor } from "./recovery-supervisor.js"; + +const SESSION_DIRECTORY_MODE = 0o700; +const SESSION_REFERENCE_FILE = "bridge-agent.json"; +const DEFAULT_EVENT_LIMIT = 1_000; + +export class UnknownAgentError extends Error { + constructor(agentId) { + super(`unknown agent: ${agentId}`); + this.name = "UnknownAgentError"; + } +} + +function sessionDirectoryFor(sessionRoot, worktreePath) { + const identity = createHash("sha256") + .update(worktreePath) + .digest("hex") + .slice(0, 24); + return path.join(sessionRoot, `agent-${identity}`); +} + +async function readSessionReference(sessionDir) { + try { + const value = JSON.parse( + await readFile(path.join(sessionDir, SESSION_REFERENCE_FILE), "utf8"), + ); + return { + sessionPath: + typeof value?.sessionPath === "string" && + path.isAbsolute(value.sessionPath) + ? value.sessionPath + : undefined, + worktreePath: + typeof value?.worktreePath === "string" && + path.isAbsolute(value.worktreePath) + ? value.worktreePath + : undefined, + }; + } catch (error) { + if (error?.code === "ENOENT" || error instanceof SyntaxError) return {}; + throw error; + } +} + +async function persistSessionReference(sessionDir, sessionPath, worktreePath) { + await writeFile( + path.join(sessionDir, SESSION_REFERENCE_FILE), + `${JSON.stringify({ sessionPath, worktreePath })}\n`, + { + encoding: "utf8", + mode: 0o600, + }, + ); +} + +function sessionPreview(content) { + if (typeof content === "string") return content; + if (!Array.isArray(content)) return ""; + return content + .filter((part) => part?.type === "text" && typeof part.text === "string") + .map((part) => part.text) + .join(" "); +} + +async function readSessionSummary(sessionPath, currentPath) { + const [content, metadata] = await Promise.all([ + readFile(sessionPath, "utf8"), + stat(sessionPath), + ]); + let header; + let name; + let firstMessage; + let messageCount = 0; + for (const line of content.split("\n")) { + if (!line) continue; + let entry; + try { + entry = JSON.parse(line); + } catch { + continue; + } + if (entry.type === "session") header = entry; + if (entry.type === "session_info" && typeof entry.name === "string") + name = entry.name; + if (entry.type === "message") { + messageCount += 1; + if (!firstMessage && entry.message?.role === "user") + firstMessage = sessionPreview(entry.message.content); + } + } + if ( + !header || + typeof header.id !== "string" || + typeof header.cwd !== "string" + ) + return undefined; + return { + path: sessionPath, + id: header.id, + cwd: header.cwd, + name, + parentSessionPath: header.parentSession, + created: header.timestamp, + modified: metadata.mtime.toISOString(), + messageCount, + firstMessage, + isCurrent: sessionPath === currentPath, + }; +} + +async function listSessionSummaries(sessionDir, currentPath) { + const entries = await readdir(sessionDir, { withFileTypes: true }); + const summaries = await Promise.all( + entries + .filter((entry) => entry.isFile() && entry.name.endsWith(".jsonl")) + .map(async (entry) => { + try { + return await readSessionSummary( + path.join(sessionDir, entry.name), + currentPath, + ); + } catch { + return undefined; + } + }), + ); + return summaries + .filter(Boolean) + .sort((left, right) => right.modified.localeCompare(left.modified)); +} + +async function listDirectoryCatalog(sessionRoot, agentsByPath) { + let entries; + try { + entries = await readdir(sessionRoot, { withFileTypes: true }); + } catch (error) { + if (error?.code === "ENOENT") return []; + throw error; + } + const references = await Promise.all( + entries + .filter((entry) => entry.isDirectory()) + .map(async (entry) => { + const reference = await readSessionReference( + path.join(sessionRoot, entry.name), + ); + if (!reference.worktreePath) return undefined; + return path.basename( + sessionDirectoryFor(sessionRoot, reference.worktreePath), + ) === entry.name + ? reference.worktreePath + : undefined; + }), + ); + const paths = new Set([ + ...agentsByPath.keys(), + ...references.filter(Boolean), + ]); + return [...paths] + .map((worktreePath) => { + const agent = agentsByPath.get(worktreePath); + return { + worktreePath, + state: agent?.state ?? "inactive", + ...(agent ? { agentId: agent.id } : {}), + }; + }) + .sort((left, right) => left.worktreePath.localeCompare(right.worktreePath)); +} + +function publicAgent(agent) { + return { + id: agent.id, + worktreePath: agent.worktreePath, + sessionDir: agent.sessionDir, + state: agent.state, + }; +} + +function routeCommand(adapter, operation, payload = {}, agentState) { + switch (operation) { + case "prompt": + case "steer": + case "follow_up": + return adapter.send({ type: operation, message: payload.message }); + case "submit_prompt": + return adapter.send({ + type: agentState === "streaming" ? "follow_up" : "prompt", + message: payload.message, + }); + case "abort": + case "get_state": + case "get_session_stats": + case "get_available_models": + case "get_commands": + return adapter.send({ type: operation }); + case "get_transcript": + return adapter.send({ type: "get_messages" }); + case "set_model": + return adapter.send({ + type: "set_model", + provider: payload.provider, + modelId: payload.modelId, + }); + case "set_thinking_level": + return adapter.send({ type: "set_thinking_level", level: payload.level }); + case "extension_response": + adapter.respondToExtension(payload.requestId, payload.response); + return Promise.resolve({ + type: "response", + command: "extension_ui_response", + success: true, + }); + default: + return Promise.reject( + new Error(`unsupported agent operation: ${operation}`), + ); + } +} + +export function createAgentRegistry({ + homeWorktree, + sessionRoot, + startAdapter = startPiRpcAdapter, + eventLimit = DEFAULT_EVENT_LIMIT, + maxRecoveryAttempts, + recoveryDelayForAttempt, + sleep, +}) { + if (typeof homeWorktree !== "string" || !path.isAbsolute(homeWorktree)) + throw new TypeError("homeWorktree must be an absolute path"); + if (typeof sessionRoot !== "string" || !path.isAbsolute(sessionRoot)) + throw new TypeError("sessionRoot must be an absolute path"); + if (typeof startAdapter !== "function") + throw new TypeError("startAdapter must be a function"); + + const agentsByPath = new Map(); + const agentsById = new Map(); + const inFlight = new Map(); + + async function ensureAgent(worktreePath) { + if (typeof worktreePath !== "string" || !path.isAbsolute(worktreePath)) + throw new TypeError("worktreePath must be an absolute path"); + const canonicalPath = await realpath(worktreePath); + const existing = agentsByPath.get(canonicalPath); + if (existing) return publicAgent(existing); + const creating = inFlight.get(canonicalPath); + if (creating) return creating; + + const creation = (async () => { + const sessionDir = sessionDirectoryFor(sessionRoot, canonicalPath); + await mkdir(sessionDir, { + recursive: true, + mode: SESSION_DIRECTORY_MODE, + }); + await chmod(sessionDir, SESSION_DIRECTORY_MODE); + const reference = await readSessionReference(sessionDir); + const agent = { + id: `agent-${randomUUID()}`, + worktreePath: canonicalPath, + sessionDir, + sessionPath: reference.sessionPath, + state: "idle", + events: [], + listeners: new Set(), + nextSequence: 1, + adapter: undefined, + stopped: false, + recoveryPromise: undefined, + supervisor: undefined, + }; + const publish = (type, data) => { + const event = { + version: "v1", + seq: agent.nextSequence++, + type, + agentId: agent.id, + data, + }; + agent.events.push(event); + if (agent.events.length > eventLimit) agent.events.shift(); + for (const listener of agent.listeners) listener(event); + }; + const launch = async () => { + const adapter = startAdapter({ + cwd: canonicalPath, + sessionDir, + ...(agent.sessionPath ? { sessionPath: agent.sessionPath } : {}), + onEvent: (event) => { + if ( + event.type === "agent_state" && + typeof event.data?.state === "string" + ) { + agent.state = event.data.state; + if (event.data.state === "idle") agent.supervisor.markHealthy(); + } + publish(event.type, event.data); + }, + onError: (error) => { + agent.state = "error"; + publish("agent_state", { + state: "error", + error: { code: error.code ?? "unknown", message: error.message }, + }); + if ( + !agent.stopped && + ["child_exited", "child_error"].includes(error.code) + ) { + agent.recoveryPromise = agent.supervisor + .handleUnexpectedExit() + .then((adapterAfterRecovery) => { + if (agent.stopped) return undefined; + agent.state = adapterAfterRecovery ? "idle" : "failed"; + publish("agent_state", { state: agent.state }); + return adapterAfterRecovery; + }); + } + }, + }); + agent.adapter = adapter; + const state = await adapter.send({ type: "get_state" }); + agent.state = state?.data?.isStreaming ? "streaming" : "idle"; + const sessionPath = state?.data?.sessionFile; + if (typeof sessionPath === "string" && path.isAbsolute(sessionPath)) { + agent.sessionPath = sessionPath; + await persistSessionReference(sessionDir, sessionPath, canonicalPath); + } + return adapter; + }; + agent.supervisor = createRecoverySupervisor({ + start: launch, + ...(maxRecoveryAttempts ? { maxAttempts: maxRecoveryAttempts } : {}), + ...(recoveryDelayForAttempt + ? { delayForAttempt: recoveryDelayForAttempt } + : {}), + ...(sleep ? { sleep } : {}), + onState: (recovery) => publish("recovery", recovery), + }); + await launch(); + agentsByPath.set(canonicalPath, agent); + agentsById.set(agent.id, agent); + return publicAgent(agent); + })(); + inFlight.set(canonicalPath, creation); + try { + return await creation; + } finally { + inFlight.delete(canonicalPath); + } + } + + function getAgent(agentId) { + const agent = agentsById.get(agentId); + if (!agent) throw new UnknownAgentError(agentId); + return agent; + } + + async function refreshAgentSessionReference(agent) { + const state = await agent.adapter.send({ type: "get_state" }); + const sessionPath = state?.data?.sessionFile; + if (typeof sessionPath === "string" && path.isAbsolute(sessionPath)) { + agent.sessionPath = sessionPath; + await persistSessionReference( + agent.sessionDir, + sessionPath, + agent.worktreePath, + ); + } + } + + async function ownedSessionPath(agent, sessionPath) { + const [resolvedSessionDir, resolvedSessionPath] = await Promise.all([ + realpath(agent.sessionDir), + realpath(sessionPath), + ]); + const relative = path.relative(resolvedSessionDir, resolvedSessionPath); + if ( + relative === "" || + relative.startsWith(`..${path.sep}`) || + path.isAbsolute(relative) || + !resolvedSessionPath.endsWith(".jsonl") + ) { + throw new TypeError("sessionPath must belong to the selected directory"); + } + return resolvedSessionPath; + } + + async function switchSession(agent, sessionPath) { + if (agent.state === "streaming") + throw new Error("cannot switch sessions while Pi is working"); + const ownedPath = await ownedSessionPath(agent, sessionPath); + const response = await agent.adapter.send({ + type: "switch_session", + sessionPath: ownedPath, + }); + if (!response?.data?.cancelled) await refreshAgentSessionReference(agent); + return response; + } + + async function newSession(agent) { + if (agent.state === "streaming") + throw new Error("cannot start a session while Pi is working"); + const response = await agent.adapter.send({ type: "new_session" }); + if (!response?.data?.cancelled) await refreshAgentSessionReference(agent); + return response; + } + + return { + async start() { + return ensureAgent(homeWorktree); + }, + async selectWorktree(worktreePath) { + return ensureAgent(worktreePath); + }, + listAgents() { + return [...agentsById.values()] + .map(publicAgent) + .sort((left, right) => + left.worktreePath.localeCompare(right.worktreePath), + ); + }, + async listDirectories() { + return listDirectoryCatalog(sessionRoot, agentsByPath); + }, + async listSessions(agentId) { + const agent = getAgent(agentId); + return listSessionSummaries(agent.sessionDir, agent.sessionPath); + }, + eventsAfter(agentId, cursor = 0) { + if (!Number.isSafeInteger(cursor) || cursor < 0) + throw new TypeError("cursor must be a non-negative integer"); + return getAgent(agentId).events.filter((event) => event.seq > cursor); + }, + subscribe(agentId, cursor, listener) { + if (typeof listener !== "function") + throw new TypeError("listener must be a function"); + const agent = getAgent(agentId); + const events = this.eventsAfter(agentId, cursor); + agent.listeners.add(listener); + return { + events, + unsubscribe: () => agent.listeners.delete(listener), + }; + }, + async waitForRecovery(agentId) { + return getAgent(agentId).recoveryPromise; + }, + async retry(agentId) { + const agent = getAgent(agentId); + agent.supervisor.markHealthy(); + agent.recoveryPromise = agent.supervisor.handleUnexpectedExit(); + await agent.recoveryPromise; + return publicAgent(agent); + }, + async restart(agentId) { + const agent = getAgent(agentId); + await agent.adapter.stop(); + return this.retry(agentId); + }, + async route(agentId, operation, payload) { + const agent = getAgent(agentId); + if (operation === "switch_session") + return switchSession(agent, payload.sessionPath); + if (operation === "new_session") return newSession(agent); + return routeCommand(agent.adapter, operation, payload, agent.state); + }, + async stop() { + await Promise.all( + [...agentsById.values()].map(async (agent) => { + agent.stopped = true; + agent.supervisor.stop(); + await agent.adapter.stop(); + agent.state = "stopped"; + }), + ); + }, + }; +} + +export const sessionDirectoryMode = SESSION_DIRECTORY_MODE; diff --git a/src/bridge/cli.js b/src/bridge/cli.js new file mode 100755 index 0000000..58f0795 --- /dev/null +++ b/src/bridge/cli.js @@ -0,0 +1,20 @@ +#!/usr/bin/env node +import { homedir } from "node:os"; +import { runBridgeDaemon } from "./daemon.js"; + +function readFlag(args, name) { + const index = args.indexOf(name); + return index >= 0 ? args[index + 1] : undefined; +} + +const args = process.argv.slice(2); +runBridgeDaemon({ + homeWorktree: readFlag(args, "--worktree") ?? homedir(), + runtimeDir: readFlag(args, "--runtime-dir"), + sessionRoot: readFlag(args, "--session-root"), +}).catch((error) => { + process.stderr.write( + `${error instanceof Error ? error.message : "bridge daemon failed"}\n`, + ); + process.exitCode = 1; +}); diff --git a/src/bridge/daemon.js b/src/bridge/daemon.js new file mode 100644 index 0000000..41e2e02 --- /dev/null +++ b/src/bridge/daemon.js @@ -0,0 +1,26 @@ +import { startBridgeService } from "./service.js"; + +export async function startBridgeDaemon(options) { + return startBridgeService(options); +} + +export async function runBridgeDaemon(options) { + const daemon = await startBridgeDaemon(options); + let stopping = false; + const stop = async () => { + if (stopping) return; + stopping = true; + await daemon.close(); + }; + process.stdout.write( + `${JSON.stringify({ socketPath: daemon.socketPath })}\n`, + ); + await new Promise((resolve) => { + const shutdown = async () => { + await stop(); + resolve(); + }; + process.once("SIGINT", shutdown); + process.once("SIGTERM", shutdown); + }); +} diff --git a/src/bridge/instance-lock.js b/src/bridge/instance-lock.js new file mode 100644 index 0000000..118d16b --- /dev/null +++ b/src/bridge/instance-lock.js @@ -0,0 +1,126 @@ +import { randomUUID } from "node:crypto"; +import { chmod, open, readFile, unlink } from "node:fs/promises"; + +const LOCK_MODE = 0o600; + +export class BridgeAlreadyRunningError extends Error { + constructor(metadata) { + super(`bridge instance is already running with pid ${metadata.pid}`); + this.name = "BridgeAlreadyRunningError"; + this.metadata = metadata; + } +} + +export class BridgeLockError extends Error { + constructor(message) { + super(message); + this.name = "BridgeLockError"; + } +} + +function parseLockMetadata(text, lockPath) { + let metadata; + try { + metadata = JSON.parse(text); + } catch { + throw new BridgeLockError(`lock file is unreadable: ${lockPath}`); + } + if ( + metadata === null || + typeof metadata !== "object" || + !Number.isSafeInteger(metadata.pid) || + metadata.pid < 1 || + typeof metadata.token !== "string" || + metadata.token.length < 1 || + typeof metadata.socketPath !== "string" || + metadata.socketPath.length < 1 || + typeof metadata.startedAt !== "string" || + Number.isNaN(Date.parse(metadata.startedAt)) + ) { + throw new BridgeLockError(`lock file has an invalid format: ${lockPath}`); + } + return metadata; +} + +export function isProcessAlive(pid) { + try { + process.kill(pid, 0); + return true; + } catch (error) { + if (error?.code === "ESRCH") return false; + if (error?.code === "EPERM") return true; + throw error; + } +} + +async function readLockMetadata(lockPath) { + try { + return parseLockMetadata(await readFile(lockPath, "utf8"), lockPath); + } catch (error) { + if (error?.code === "ENOENT") return undefined; + throw error; + } +} + +export async function acquireBridgeLock({ + lockPath, + socketPath, + pid = process.pid, + now = () => new Date(), + processAlive = isProcessAlive, +}) { + if (!lockPath || !socketPath) + throw new TypeError("lockPath and socketPath are required"); + + const metadata = { + pid, + socketPath, + startedAt: now().toISOString(), + token: randomUUID(), + }; + + for (let attempt = 0; attempt < 2; attempt += 1) { + let handle; + let createdLock = false; + try { + handle = await open(lockPath, "wx", LOCK_MODE); + createdLock = true; + await handle.writeFile(`${JSON.stringify(metadata)}\n`, "utf8"); + await handle.sync(); + await chmod(lockPath, LOCK_MODE); + await handle.close(); + return { + metadata, + async release() { + const current = await readLockMetadata(lockPath); + if (current?.token !== metadata.token) return false; + await unlink(lockPath); + return true; + }, + }; + } catch (error) { + await handle?.close().catch(() => {}); + if (createdLock) { + await unlink(lockPath).catch(() => {}); + throw error; + } + if (error?.code !== "EEXIST") throw error; + + const existing = await readLockMetadata(lockPath); + if (!existing) continue; + if (processAlive(existing.pid)) + throw new BridgeAlreadyRunningError(existing); + + // Only a lock with a readable PID that has exited may be reclaimed. + await unlink(lockPath).catch((unlinkError) => { + if (unlinkError?.code !== "ENOENT") throw unlinkError; + }); + } + } + + throw new BridgeLockError( + `could not acquire lock after stale-owner recovery: ${lockPath}`, + ); +} + +export const lockMode = LOCK_MODE; diff --git a/src/bridge/pi-rpc-adapter.js b/src/bridge/pi-rpc-adapter.js new file mode 100644 index 0000000..b00d028 --- /dev/null +++ b/src/bridge/pi-rpc-adapter.js @@ -0,0 +1,367 @@ +import { randomUUID } from "node:crypto"; +import { spawn } from "node:child_process"; +import path from "node:path"; +import { StringDecoder } from "node:string_decoder"; + +export const DEFAULT_COMMAND_TIMEOUT_MS = 30_000; +export const MAX_PI_RPC_FRAME_BYTES = 1024 * 1024; + +const supportedCommands = new Set([ + "prompt", + "steer", + "follow_up", + "abort", + "get_state", + "get_session_stats", + "new_session", + "switch_session", + "get_messages", + "get_available_models", + "get_commands", + "set_model", + "set_thinking_level", +]); + +export class PiRpcError extends Error { + constructor(code, message) { + super(message); + this.name = "PiRpcError"; + this.code = code; + } +} + +function isRecord(value) { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function assertAbsolutePath(value, field) { + if (typeof value !== "string" || !path.isAbsolute(value)) { + throw new TypeError(`${field} must be an absolute path`); + } +} + +function emitSafely(callback, value) { + try { + callback(value); + } catch { + // Adapter observers must not interrupt the RPC reader. + } +} + +function normalizePiEvent(event) { + switch (event.type) { + case "agent_start": + return { type: "agent_state", data: { state: "streaming", event } }; + case "agent_end": + // A low-level run can be followed by compaction, retry, or queued work. + // Only agent_settled is an authoritative idle transition. + return { type: "transcript", data: { event } }; + case "agent_settled": + return { type: "agent_state", data: { state: "idle", event } }; + case "turn_start": + case "turn_end": + case "message_start": + case "message_end": + return { type: "transcript", data: { event } }; + case "message_update": + return { type: "stream", data: { event } }; + case "tool_execution_start": + case "tool_execution_update": + case "tool_execution_end": + return { type: "tool", data: { event } }; + case "queue_update": + return { type: "queue", data: { event } }; + case "compaction_start": + case "compaction_end": + case "auto_retry_start": + case "auto_retry_end": + return { type: "recovery", data: { event } }; + case "extension_ui_request": + return { type: "extension_ui_request", data: { event } }; + case "extension_error": + // Extension errors are diagnostics; the Pi process can still be healthy. + return { type: "transcript", data: { event } }; + default: + return undefined; + } +} + +function parseFrame(line, onError) { + try { + const value = JSON.parse(line); + if (!isRecord(value) || typeof value.type !== "string") { + throw new PiRpcError( + "invalid_message", + "Pi RPC frame must be an object with a type", + ); + } + return value; + } catch (error) { + onError( + error instanceof PiRpcError + ? error + : new PiRpcError("invalid_json", "Pi RPC emitted invalid JSON"), + ); + return undefined; + } +} + +function serializeFrame(value) { + try { + return `${JSON.stringify(value)}\n`; + } catch { + throw new PiRpcError( + "invalid_command", + "Pi RPC command must be JSON-serializable", + ); + } +} + +export function startPiRpcAdapter({ + cwd, + sessionDir, + sessionPath, + command = "pi", + spawnProcess = spawn, + onEvent = () => {}, + onError = () => {}, + commandTimeoutMs = DEFAULT_COMMAND_TIMEOUT_MS, + maxFrameBytes = MAX_PI_RPC_FRAME_BYTES, +}) { + assertAbsolutePath(cwd, "cwd"); + assertAbsolutePath(sessionDir, "sessionDir"); + if (sessionPath !== undefined) assertAbsolutePath(sessionPath, "sessionPath"); + if (typeof spawnProcess !== "function") + throw new TypeError("spawnProcess must be a function"); + + const child = spawnProcess( + command, + [ + "--mode", + "rpc", + "--session-dir", + sessionDir, + ...(sessionPath ? ["--session", sessionPath] : []), + ], + { + cwd, + stdio: ["pipe", "pipe", "pipe"], + }, + ); + if (!child?.stdin || !child.stdout || !child.stderr) { + throw new PiRpcError( + "spawn_failed", + "Pi RPC child must expose stdin, stdout, and stderr streams", + ); + } + + let sequence = 0; + let stdoutBuffer = ""; + let closed = false; + let intentionalStop = false; + const decoder = new StringDecoder("utf8"); + const pending = new Map(); + let resolveExit; + const exited = new Promise((resolve) => { + resolveExit = resolve; + }); + + const reportError = (error) => emitSafely(onError, error); + const rejectPending = (error) => { + for (const entry of pending.values()) { + clearTimeout(entry.timeout); + entry.reject(error); + } + pending.clear(); + }; + const finish = ({ code, signal, error }) => { + if (closed) return; + closed = true; + const terminalError = + error ?? + new PiRpcError( + "child_exited", + `Pi RPC child exited before responding (code ${code ?? "null"}, signal ${signal ?? "none"})`, + ); + rejectPending(terminalError); + if (!intentionalStop) reportError(terminalError); + resolveExit({ code, signal }); + }; + const handleResponse = (response) => { + if (typeof response.id !== "string") { + reportError( + new PiRpcError( + "uncorrelated_response", + "Pi RPC response did not include an id", + ), + ); + return; + } + const entry = pending.get(response.id); + if (!entry) { + reportError( + new PiRpcError( + "uncorrelated_response", + `Pi RPC response has no pending command: ${response.id}`, + ), + ); + return; + } + clearTimeout(entry.timeout); + pending.delete(response.id); + entry.resolve(response); + }; + const handleEvent = (event) => { + const normalized = normalizePiEvent(event); + if (!normalized) { + reportError( + new PiRpcError( + "unsupported_event", + `Pi RPC emitted unsupported event: ${event.type}`, + ), + ); + return; + } + emitSafely(onEvent, { seq: ++sequence, ...normalized }); + }; + const handleLine = (line) => { + const frame = parseFrame( + line.endsWith("\r") ? line.slice(0, -1) : line, + reportError, + ); + if (!frame) return; + if (frame.type === "response") handleResponse(frame); + else handleEvent(frame); + }; + + child.stdout.on("data", (chunk) => { + if (closed) return; + stdoutBuffer += decoder.write(chunk); + let newlineIndex; + while ((newlineIndex = stdoutBuffer.indexOf("\n")) !== -1) { + const line = stdoutBuffer.slice(0, newlineIndex); + stdoutBuffer = stdoutBuffer.slice(newlineIndex + 1); + handleLine(line); + } + if (Buffer.byteLength(stdoutBuffer, "utf8") > maxFrameBytes) { + reportError( + new PiRpcError( + "frame_too_large", + `Pi RPC frame exceeds ${maxFrameBytes} bytes`, + ), + ); + stdoutBuffer = ""; + } + }); + child.stdout.on("end", () => { + if (closed) return; + const tail = stdoutBuffer + decoder.end(); + stdoutBuffer = ""; + if (tail.length > 0) + reportError( + new PiRpcError( + "unterminated_frame", + "Pi RPC stdout ended without an LF-terminated frame", + ), + ); + }); + child.stderr.on("data", () => {}); + child.stderr.on("error", (error) => + reportError(new PiRpcError("stderr_error", error.message)), + ); + child.on("error", (error) => + finish({ + code: null, + signal: null, + error: new PiRpcError("child_error", error.message), + }), + ); + child.on("exit", (code, signal) => finish({ code, signal })); + + function write(value) { + if (closed || child.stdin.destroyed) + throw new PiRpcError("child_exited", "Pi RPC child is not available"); + child.stdin.write(serializeFrame(value)); + } + + return { + child, + get sequence() { + return sequence; + }, + send(commandInput) { + if (!isRecord(commandInput) || typeof commandInput.type !== "string") { + return Promise.reject( + new PiRpcError( + "invalid_command", + "Pi RPC command must include a type", + ), + ); + } + if (Object.hasOwn(commandInput, "id")) { + return Promise.reject( + new PiRpcError( + "invalid_command", + "command IDs are assigned by the adapter", + ), + ); + } + if (!supportedCommands.has(commandInput.type)) { + return Promise.reject( + new PiRpcError( + "unsupported_command", + `Pi RPC command is not supported: ${commandInput.type}`, + ), + ); + } + + const id = `bridge-${randomUUID()}`; + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + pending.delete(id); + reject( + new PiRpcError( + "command_timeout", + `Pi RPC command timed out: ${commandInput.type}`, + ), + ); + }, commandTimeoutMs); + timeout.unref?.(); + pending.set(id, { resolve, reject, timeout }); + try { + write({ ...commandInput, id }); + } catch (error) { + clearTimeout(timeout); + pending.delete(id); + reject(error); + } + }); + }, + respondToExtension(requestId, response) { + if ( + typeof requestId !== "string" || + requestId.length === 0 || + !isRecord(response) + ) { + throw new PiRpcError( + "invalid_extension_response", + "extension response requires a request id and response object", + ); + } + // Pi uses this id to resume the blocked extension dialog; it cannot be replaced by a command ID. + write({ type: "extension_ui_response", id: requestId, ...response }); + }, + async stop() { + if (closed) return exited; + intentionalStop = true; + rejectPending( + new PiRpcError( + "child_stopped", + "Pi RPC child was stopped by the bridge", + ), + ); + child.kill("SIGTERM"); + return exited; + }, + }; +} diff --git a/src/bridge/recovery-supervisor.js b/src/bridge/recovery-supervisor.js new file mode 100644 index 0000000..0bab167 --- /dev/null +++ b/src/bridge/recovery-supervisor.js @@ -0,0 +1,76 @@ +export const DEFAULT_MAX_RECOVERY_ATTEMPTS = 3; + +export function exponentialBackoff( + attempt, + baseDelayMs = 1_000, + maxDelayMs = 30_000, +) { + return Math.min(baseDelayMs * 2 ** (attempt - 1), maxDelayMs); +} + +export function createRecoverySupervisor({ + start, + maxAttempts = DEFAULT_MAX_RECOVERY_ATTEMPTS, + delayForAttempt = (attempt) => exponentialBackoff(attempt), + sleep = (delay) => new Promise((resolve) => setTimeout(resolve, delay)), + onState = () => {}, +}) { + if (typeof start !== "function") + throw new TypeError("start must be a function"); + if (!Number.isSafeInteger(maxAttempts) || maxAttempts < 1) + throw new TypeError("maxAttempts must be a positive integer"); + + let attempts = 0; + let stopped = false; + let recovery; + + const emit = (state) => { + try { + onState(state); + } catch { + // Observers cannot interrupt recovery. + } + }; + + async function recover() { + while (!stopped && attempts < maxAttempts) { + attempts += 1; + const attempt = attempts; + const delayMs = delayForAttempt(attempt); + emit({ state: "recovering", attempt, delayMs }); + await sleep(delayMs); + if (stopped) return undefined; + try { + const resource = await start(); + if (stopped) return undefined; + emit({ state: "healthy", attempt }); + return resource; + } catch (error) { + emit({ state: "recovery_error", attempt, error }); + } + } + if (!stopped) emit({ state: "failed", attempt: attempts }); + return undefined; + } + + return { + get attempts() { + return attempts; + }, + async handleUnexpectedExit() { + if (stopped) return undefined; + if (!recovery) { + recovery = recover().finally(() => { + recovery = undefined; + }); + } + return recovery; + }, + markHealthy() { + attempts = 0; + }, + stop() { + stopped = true; + }, + }; +} diff --git a/src/bridge/runtime.js b/src/bridge/runtime.js new file mode 100644 index 0000000..4ac3b84 --- /dev/null +++ b/src/bridge/runtime.js @@ -0,0 +1,70 @@ +import { chmod, lstat, mkdir, unlink } from "node:fs/promises"; +import path from "node:path"; +import { acquireBridgeLock } from "./instance-lock.js"; +import { startUnixSocketServer } from "./unix-server.js"; + +const RUNTIME_DIRECTORY_MODE = 0o700; + +export async function createRuntimePaths( + runtimeDir = process.env.XDG_RUNTIME_DIR, +) { + if (!runtimeDir || !path.isAbsolute(runtimeDir)) { + throw new Error("XDG_RUNTIME_DIR must be an absolute path"); + } + const directory = path.join(runtimeDir, "pi-status-bridge"); + await mkdir(directory, { recursive: true, mode: RUNTIME_DIRECTORY_MODE }); + await chmod(directory, RUNTIME_DIRECTORY_MODE); + return { + directory, + socketPath: path.join(directory, "bridge.sock"), + lockPath: path.join(directory, "bridge.lock"), + }; +} + +async function removeStaleSocket(socketPath) { + try { + const socket = await lstat(socketPath); + if (!socket.isSocket()) + throw new Error(`refusing to remove non-socket path: ${socketPath}`); + await unlink(socketPath); + } catch (error) { + if (error?.code !== "ENOENT") throw error; + } +} + +export async function startBridgeServer({ + handleRequest, + subscribe, + runtimeDir, + processAlive, + maxFrameBytes, +}) { + const paths = await createRuntimePaths(runtimeDir); + const lock = await acquireBridgeLock({ + lockPath: paths.lockPath, + socketPath: paths.socketPath, + ...(processAlive ? { processAlive } : {}), + }); + + try { + await removeStaleSocket(paths.socketPath); + const socketServer = await startUnixSocketServer({ + socketPath: paths.socketPath, + handleRequest, + ...(subscribe ? { subscribe } : {}), + ...(maxFrameBytes ? { maxFrameBytes } : {}), + }); + return { + ...paths, + async close() { + await socketServer.close(); + await lock.release(); + }, + }; + } catch (error) { + await lock.release(); + throw error; + } +} + +export const runtimeDirectoryMode = RUNTIME_DIRECTORY_MODE; diff --git a/src/bridge/service.js b/src/bridge/service.js new file mode 100644 index 0000000..f920907 --- /dev/null +++ b/src/bridge/service.js @@ -0,0 +1,88 @@ +import path from "node:path"; +import { createAgentRegistry, UnknownAgentError } from "./agent-registry.js"; +import { startPiRpcAdapter } from "./pi-rpc-adapter.js"; +import { ProtocolError } from "../protocol/index.js"; +import { createRuntimePaths, startBridgeServer } from "./runtime.js"; + +export async function startBridgeService({ + homeWorktree, + runtimeDir, + sessionRoot, + startAdapter = startPiRpcAdapter, + processAlive, + maxFrameBytes, +}) { + const paths = await createRuntimePaths(runtimeDir); + const registry = createAgentRegistry({ + homeWorktree, + sessionRoot: sessionRoot ?? path.join(paths.directory, "sessions"), + startAdapter, + }); + + const dispatch = async (request) => { + try { + switch (request.op) { + case "list_agents": + return { agents: registry.listAgents() }; + case "list_directories": + return { directories: await registry.listDirectories() }; + case "select_agent": + return { + agent: await registry.selectWorktree(request.payload.worktreePath), + }; + case "subscribe": + return { + events: registry.eventsAfter( + request.agentId, + request.payload.cursor ?? 0, + ), + }; + case "retry": + return { agent: await registry.retry(request.agentId) }; + case "restart": + return { agent: await registry.restart(request.agentId) }; + case "list_sessions": + return { sessions: await registry.listSessions(request.agentId) }; + default: + return registry.route(request.agentId, request.op, request.payload); + } + } catch (error) { + if (error instanceof UnknownAgentError) + throw new ProtocolError("unknown_agent", error.message); + throw error; + } + }; + + const server = await startBridgeServer({ + runtimeDir, + handleRequest: dispatch, + subscribe: (request, notify) => { + const subscription = registry.subscribe( + request.agentId, + request.payload.cursor ?? 0, + notify, + ); + return { + result: { events: subscription.events }, + unsubscribe: subscription.unsubscribe, + }; + }, + ...(processAlive ? { processAlive } : {}), + ...(maxFrameBytes ? { maxFrameBytes } : {}), + }); + try { + await registry.start(); + } catch (error) { + await server.close(); + throw error; + } + + return { + socketPath: server.socketPath, + listAgents: () => registry.listAgents(), + async close() { + await registry.stop(); + await server.close(); + }, + }; +} diff --git a/src/bridge/unix-server.js b/src/bridge/unix-server.js new file mode 100644 index 0000000..2682ace --- /dev/null +++ b/src/bridge/unix-server.js @@ -0,0 +1,164 @@ +import { createServer } from "node:net"; +import { StringDecoder } from "node:string_decoder"; +import { chmod, lstat, unlink } from "node:fs/promises"; +import { + MAX_FRAME_BYTES, + ProtocolError, + encodeFrame, + errorResponse, + parseRequestFrame, + successResponse, +} from "../protocol/index.js"; + +const OWNER_ONLY_MODE = 0o600; + +function isMissing(error) { + return error && error.code === "ENOENT"; +} + +async function assertUnusedSocketPath(socketPath) { + try { + const stats = await lstat(socketPath); + if (stats.isSocket()) + throw new Error(`socket path is already in use: ${socketPath}`); + throw new Error(`refusing to replace non-socket path: ${socketPath}`); + } catch (error) { + if (!isMissing(error)) throw error; + } +} + +function requestIdFromLine(line) { + try { + const value = JSON.parse(line); + return typeof value?.id === "string" ? value.id : undefined; + } catch { + return undefined; + } +} + +function write(socket, frame) { + if (!socket.destroyed) socket.write(encodeFrame(frame)); +} + +export async function startUnixSocketServer({ + socketPath, + handleRequest, + subscribe, + maxFrameBytes = MAX_FRAME_BYTES, +}) { + if (typeof socketPath !== "string" || !socketPath.startsWith("/")) { + throw new Error("socketPath must be an absolute Unix-socket path"); + } + if (typeof handleRequest !== "function") + throw new TypeError("handleRequest must be a function"); + if (subscribe !== undefined && typeof subscribe !== "function") + throw new TypeError("subscribe must be a function"); + await assertUnusedSocketPath(socketPath); + + const sockets = new Set(); + const server = createServer((socket) => { + sockets.add(socket); + socket.on("error", () => {}); + const decoder = new StringDecoder("utf8"); + const cleanup = new Set(); + let pending = ""; + let closedForFrameLimit = false; + + socket.on("close", () => { + sockets.delete(socket); + for (const unsubscribe of cleanup) unsubscribe(); + cleanup.clear(); + }); + + const handleSubscription = async (request) => { + const queued = []; + let ready = false; + const subscription = await subscribe(request, (event) => { + if (ready) write(socket, { version: "v1", type: "event", event }); + else queued.push(event); + }); + cleanup.add(subscription.unsubscribe); + write(socket, successResponse(request.id, subscription.result)); + ready = true; + for (const event of queued) + write(socket, { version: "v1", type: "event", event }); + }; + + const handleLine = async (line) => { + const normalized = line.endsWith("\r") ? line.slice(0, -1) : line; + try { + const request = parseRequestFrame(normalized, maxFrameBytes); + if (request.op === "subscribe" && subscribe) { + await handleSubscription(request); + return; + } + const result = await handleRequest(request); + write(socket, successResponse(request.id, result)); + } catch (error) { + write(socket, errorResponse(requestIdFromLine(normalized), error)); + } + }; + + socket.on("data", (chunk) => { + if (closedForFrameLimit) return; + pending += decoder.write(chunk); + let newline; + while ((newline = pending.indexOf("\n")) !== -1) { + const line = pending.slice(0, newline); + pending = pending.slice(newline + 1); + void handleLine(line); + } + if (Buffer.byteLength(pending, "utf8") > maxFrameBytes) { + closedForFrameLimit = true; + write( + socket, + errorResponse( + undefined, + new ProtocolError( + "frame_too_large", + `frame exceeds ${maxFrameBytes} bytes`, + ), + ), + ); + socket.end(); + } + }); + + socket.on("end", () => { + if (closedForFrameLimit) return; + const finalLine = pending + decoder.end(); + if (finalLine.length > 0) void handleLine(finalLine); + }); + }); + + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(socketPath, () => { + server.off("error", reject); + resolve(); + }); + }); + + try { + await chmod(socketPath, OWNER_ONLY_MODE); + } catch (error) { + await new Promise((resolve) => server.close(resolve)); + await unlink(socketPath).catch(() => {}); + throw error; + } + + return { + socketPath, + async close() { + for (const socket of sockets) socket.destroy(); + await new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())), + ); + await unlink(socketPath).catch((error) => { + if (!isMissing(error)) throw error; + }); + }, + }; +} + +export const socketMode = OWNER_ONLY_MODE; diff --git a/src/client/cli.js b/src/client/cli.js new file mode 100755 index 0000000..63b2351 --- /dev/null +++ b/src/client/cli.js @@ -0,0 +1,89 @@ +#!/usr/bin/env node +import { connectLocalClient } from "./local-client.js"; + +function usage() { + return [ + "Usage:", + " pi-status-bridge-client --socket request ''", + " pi-status-bridge-client --socket subscribe --agent [--cursor ]", + ].join("\n"); +} + +function fail(message) { + process.stderr.write(`${message}\n`); + process.exitCode = 1; +} + +function flag(args, name) { + const index = args.indexOf(name); + return index >= 0 ? args[index + 1] : undefined; +} + +async function requestOnce(client, rawRequest) { + let request; + try { + request = JSON.parse(rawRequest); + } catch { + throw new Error("request must be valid JSON"); + } + if ( + !request || + typeof request !== "object" || + Array.isArray(request) || + typeof request.op !== "string" + ) { + throw new Error("request must be an object with an op"); + } + const result = await client.request(request.op, { + ...(request.agentId ? { agentId: request.agentId } : {}), + ...(request.payload === undefined ? {} : { payload: request.payload }), + }); + process.stdout.write(`${JSON.stringify(result)}\n`); +} + +async function streamSubscription(client, args) { + const agentId = flag(args, "--agent"); + const cursorValue = flag(args, "--cursor"); + if (!agentId) throw new Error(usage()); + const cursor = + cursorValue === undefined ? 0 : Number.parseInt(cursorValue, 10); + if (!Number.isSafeInteger(cursor) || cursor < 0) + throw new Error("cursor must be a non-negative integer"); + const unsubscribe = await client.subscribe(agentId, cursor, (event) => { + process.stdout.write(`${JSON.stringify(event)}\n`); + }); + await new Promise((resolve) => { + const stop = () => resolve(); + process.once("SIGINT", stop); + process.once("SIGTERM", stop); + }); + unsubscribe(); +} + +async function main(args) { + const socketPath = + flag(args, "--socket") ?? process.env.PI_STATUS_BRIDGE_SOCKET; + const remaining = args.filter( + (_, index) => args[index - 1] !== "--socket" && args[index] !== "--socket", + ); + if (!socketPath) throw new Error(usage()); + + const client = await connectLocalClient({ socketPath }); + try { + if (remaining[0] === "request" && remaining[1] && remaining.length === 2) { + await requestOnce(client, remaining[1]); + return; + } + if (remaining[0] === "subscribe") { + await streamSubscription(client, remaining.slice(1)); + return; + } + throw new Error(usage()); + } finally { + await client.close(); + } +} + +main(process.argv.slice(2)).catch((error) => + fail(error instanceof Error ? error.message : "client failed"), +); diff --git a/src/client/local-client.js b/src/client/local-client.js new file mode 100644 index 0000000..13afc05 --- /dev/null +++ b/src/client/local-client.js @@ -0,0 +1,166 @@ +import { randomUUID } from "node:crypto"; +import { createConnection } from "node:net"; +import path from "node:path"; +import { StringDecoder } from "node:string_decoder"; +import { + MAX_FRAME_BYTES, + ProtocolError, + validateRequest, +} from "../protocol/index.js"; + +export class LocalClientError extends Error { + constructor(code, message) { + super(message); + this.name = "LocalClientError"; + this.code = code; + } +} + +function frame(value) { + return `${JSON.stringify(value)}\n`; +} + +export async function connectLocalClient({ + socketPath, + maxFrameBytes = MAX_FRAME_BYTES, +}) { + if (typeof socketPath !== "string" || !path.isAbsolute(socketPath)) { + throw new TypeError("socketPath must be an absolute Unix-socket path"); + } + + const socket = await new Promise((resolve, reject) => { + const connection = createConnection(socketPath); + connection.once("connect", () => resolve(connection)); + connection.once("error", reject); + }); + const decoder = new StringDecoder("utf8"); + const pending = new Map(); + const subscriptions = new Map(); + let buffer = ""; + let closed = false; + + const rejectPending = (error) => { + for (const { reject } of pending.values()) reject(error); + pending.clear(); + }; + const dispatchEvent = (event) => { + const subscription = subscriptions.get(event.agentId); + if (!subscription) return; + if (subscription.ready) subscription.listener(event); + else subscription.queued.push(event); + }; + const dispatchLine = (line) => { + let message; + try { + message = JSON.parse(line.endsWith("\r") ? line.slice(0, -1) : line); + } catch { + rejectPending( + new LocalClientError("invalid_json", "bridge emitted invalid JSON"), + ); + return; + } + if (message?.type === "event" && message.event) { + dispatchEvent(message.event); + return; + } + if (typeof message?.id !== "string") return; + const request = pending.get(message.id); + if (!request) return; + pending.delete(message.id); + if (message.ok) request.resolve(message.result); + else + request.reject( + new LocalClientError( + message.error?.code ?? "bridge_error", + message.error?.message ?? "bridge request failed", + ), + ); + }; + + socket.on("data", (chunk) => { + if (closed) return; + buffer += decoder.write(chunk); + let index; + while ((index = buffer.indexOf("\n")) !== -1) { + const line = buffer.slice(0, index); + buffer = buffer.slice(index + 1); + dispatchLine(line); + } + if (Buffer.byteLength(buffer, "utf8") > maxFrameBytes) { + rejectPending( + new LocalClientError( + "frame_too_large", + `bridge frame exceeds ${maxFrameBytes} bytes`, + ), + ); + socket.destroy(); + } + }); + socket.on("error", (error) => + rejectPending(new LocalClientError("socket_error", error.message)), + ); + socket.on("close", () => { + closed = true; + rejectPending( + new LocalClientError("socket_closed", "bridge socket closed"), + ); + }); + + const request = (op, { agentId, payload } = {}) => { + if (closed || socket.destroyed) + return Promise.reject( + new LocalClientError("socket_closed", "bridge socket is closed"), + ); + const id = `client-${randomUUID()}`; + let requestFrame; + try { + requestFrame = validateRequest({ + version: "v1", + id, + op, + ...(agentId ? { agentId } : {}), + ...(payload === undefined ? {} : { payload }), + }); + } catch (error) { + return Promise.reject( + error instanceof ProtocolError + ? error + : new LocalClientError("invalid_request", "invalid bridge request"), + ); + } + return new Promise((resolve, reject) => { + pending.set(id, { resolve, reject }); + socket.write(frame(requestFrame)); + }); + }; + + return { + request, + async subscribe(agentId, cursor, listener) { + if (typeof listener !== "function") + throw new TypeError("listener must be a function"); + const subscription = { listener, queued: [], ready: false }; + subscriptions.set(agentId, subscription); + try { + const result = await request("subscribe", { + agentId, + payload: cursor === undefined ? {} : { cursor }, + }); + for (const event of result.events) listener(event); + subscription.ready = true; + for (const event of subscription.queued) listener(event); + subscription.queued = []; + } catch (error) { + subscriptions.delete(agentId); + throw error; + } + return () => subscriptions.delete(agentId); + }, + async close() { + if (closed) return; + closed = true; + socket.end(); + socket.destroy(); + }, + }; +} diff --git a/src/client/noctalia-ipc.js b/src/client/noctalia-ipc.js new file mode 100644 index 0000000..e54d8b4 --- /dev/null +++ b/src/client/noctalia-ipc.js @@ -0,0 +1,35 @@ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); + +export async function publishNoctaliaState( + state, + { execute = async (command, args) => execFileAsync(command, args) } = {}, +) { + if (!state || typeof state !== "object") + throw new TypeError("state is required"); + const { state: bridgeState, projectLabel, attentionCount, detail } = state; + if ( + typeof bridgeState !== "string" || + typeof projectLabel !== "string" || + !Number.isSafeInteger(attentionCount) || + typeof detail !== "string" + ) { + throw new TypeError( + "state must contain string state/projectLabel/detail and integer attentionCount", + ); + } + await execute("qs", [ + "-c", + "noctalia-shell", + "ipc", + "call", + "plugin:pi-status-bridge", + "setBridgeState", + bridgeState, + projectLabel, + String(attentionCount), + detail, + ]); +} diff --git a/src/client/noctalia-relay-cli.js b/src/client/noctalia-relay-cli.js new file mode 100755 index 0000000..765910b --- /dev/null +++ b/src/client/noctalia-relay-cli.js @@ -0,0 +1,48 @@ +#!/usr/bin/env node +import { connectLocalClient } from "./local-client.js"; +import { publishNoctaliaState } from "./noctalia-ipc.js"; +import { createNoctaliaStateRelay } from "./noctalia-relay.js"; + +function flag(args, name) { + const index = args.indexOf(name); + return index >= 0 ? args[index + 1] : undefined; +} + +async function run() { + const args = process.argv.slice(2); + const socketPath = + flag(args, "--socket") ?? process.env.PI_STATUS_BRIDGE_SOCKET; + const agentId = flag(args, "--agent"); + if (!socketPath || !agentId) + throw new Error( + "Usage: pi-status-bridge-noctalia-relay --socket --agent ", + ); + const client = await connectLocalClient({ socketPath }); + const agents = await client.request("list_agents"); + const agent = agents.agents.find((candidate) => candidate.id === agentId); + if (!agent) throw new Error(`unknown agent: ${agentId}`); + const relay = createNoctaliaStateRelay({ + client, + agent, + onState: (state) => { + void publishNoctaliaState(state).catch((error) => + process.stderr.write(`${error.message}\n`), + ); + }, + }); + await relay.start(); + await new Promise((resolve) => { + const shutdown = () => resolve(); + process.once("SIGINT", shutdown); + process.once("SIGTERM", shutdown); + }); + relay.stop(); + await client.close(); +} + +run().catch((error) => { + process.stderr.write( + `${error instanceof Error ? error.message : "Noctalia relay failed"}\n`, + ); + process.exitCode = 1; +}); diff --git a/src/client/noctalia-relay.js b/src/client/noctalia-relay.js new file mode 100644 index 0000000..67e376c --- /dev/null +++ b/src/client/noctalia-relay.js @@ -0,0 +1,60 @@ +import path from "node:path"; + +function detailFor(state) { + switch (state) { + case "streaming": + return "Streaming"; + case "recovering": + return "Recovering Pi session"; + case "failed": + return "Recovery failed"; + case "error": + return "Bridge error"; + default: + return "Idle"; + } +} + +export function createNoctaliaStateRelay({ client, agent, onState }) { + if ( + !client || + typeof client.request !== "function" || + typeof client.subscribe !== "function" + ) + throw new TypeError("client must support request and subscribe"); + if (!agent?.id || !agent?.worktreePath) + throw new TypeError("agent id and worktreePath are required"); + if (typeof onState !== "function") + throw new TypeError("onState must be a function"); + + let state = "idle"; + let attentionCount = 0; + let unsubscribe = () => {}; + const projectLabel = path.basename(agent.worktreePath) || agent.worktreePath; + const publish = () => + onState({ state, projectLabel, attentionCount, detail: detailFor(state) }); + const handleEvent = (event) => { + if (event.type === "agent_state" && typeof event.data?.state === "string") + state = event.data.state; + if (event.type === "queue") + attentionCount = + (event.data?.event?.steering?.length ?? 0) + + (event.data?.event?.followUp?.length ?? 0); + if (event.type === "extension_ui_request") + attentionCount = Math.max(attentionCount, 1); + publish(); + }; + + return { + async start() { + const response = await client.request("get_state", { agentId: agent.id }); + state = response?.data?.isStreaming ? "streaming" : "idle"; + publish(); + unsubscribe = await client.subscribe(agent.id, 0, handleEvent); + }, + stop() { + unsubscribe(); + unsubscribe = () => {}; + }, + }; +} diff --git a/src/protocol/index.js b/src/protocol/index.js new file mode 100644 index 0000000..ee00cbc --- /dev/null +++ b/src/protocol/index.js @@ -0,0 +1,341 @@ +import path from "node:path"; + +export const PROTOCOL_VERSION = "v1"; +export const MAX_FRAME_BYTES = 64 * 1024; + +const requestOperations = new Map([ + ["list_agents", { agent: false, payload: "none" }], + ["list_directories", { agent: false, payload: "none" }], + ["select_agent", { agent: false, payload: "worktree" }], + ["get_state", { agent: true, payload: "none" }], + ["get_session_stats", { agent: true, payload: "none" }], + ["list_sessions", { agent: true, payload: "none" }], + ["new_session", { agent: true, payload: "none" }], + ["switch_session", { agent: true, payload: "session" }], + ["get_transcript", { agent: true, payload: "none" }], + ["subscribe", { agent: true, payload: "cursor" }], + ["prompt", { agent: true, payload: "message" }], + ["submit_prompt", { agent: true, payload: "message" }], + ["steer", { agent: true, payload: "message" }], + ["follow_up", { agent: true, payload: "message" }], + ["abort", { agent: true, payload: "none" }], + ["extension_response", { agent: true, payload: "extensionResponse" }], + ["retry", { agent: true, payload: "none" }], + ["restart", { agent: true, payload: "none" }], + ["get_available_models", { agent: true, payload: "none" }], + ["get_commands", { agent: true, payload: "none" }], + ["set_model", { agent: true, payload: "model" }], + ["set_thinking_level", { agent: true, payload: "thinking" }], +]); + +const eventTypes = new Set([ + "agent_state", + "transcript", + "stream", + "tool", + "queue", + "recovery", + "extension_ui_request", +]); + +const thinkingLevels = new Set([ + "off", + "minimal", + "low", + "medium", + "high", + "xhigh", + "max", +]); +const idPattern = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/; + +export class ProtocolError extends Error { + constructor(code, message) { + super(message); + this.name = "ProtocolError"; + this.code = code; + } +} + +function isRecord(value) { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function assertRecord(value, field) { + if (!isRecord(value)) + throw new ProtocolError("invalid_message", `${field} must be an object`); + return value; +} + +function assertAllowedKeys(value, allowed, field) { + for (const key of Object.keys(value)) { + if (!allowed.has(key)) + throw new ProtocolError( + "invalid_message", + `${field}.${key} is not supported`, + ); + } +} + +function assertString(value, field, { maxLength = 4096, pattern } = {}) { + if ( + typeof value !== "string" || + value.length === 0 || + value.length > maxLength + ) { + throw new ProtocolError( + "invalid_message", + `${field} must be a non-empty string up to ${maxLength} characters`, + ); + } + if (pattern && !pattern.test(value)) + throw new ProtocolError( + "invalid_message", + `${field} has an invalid format`, + ); + return value; +} + +function assertOptionalCursor(value) { + if (value === undefined) return undefined; + if (Number.isSafeInteger(value) && value >= 0) return value; + return assertString(value, "payload.cursor", { + maxLength: 256, + pattern: idPattern, + }); +} + +function validatePayload(kind, value) { + if (kind === "none") { + if (value !== undefined) + throw new ProtocolError( + "invalid_message", + "payload is not allowed for this operation", + ); + return undefined; + } + + const payload = assertRecord(value, "payload"); + switch (kind) { + case "worktree": { + assertAllowedKeys(payload, new Set(["worktreePath"]), "payload"); + const worktreePath = assertString( + payload.worktreePath, + "payload.worktreePath", + { maxLength: 4096 }, + ); + if (!path.isAbsolute(worktreePath)) + throw new ProtocolError( + "invalid_message", + "payload.worktreePath must be absolute", + ); + return { worktreePath }; + } + case "session": { + assertAllowedKeys(payload, new Set(["sessionPath"]), "payload"); + const sessionPath = assertString( + payload.sessionPath, + "payload.sessionPath", + { + maxLength: 4096, + }, + ); + if (!path.isAbsolute(sessionPath)) + throw new ProtocolError( + "invalid_message", + "payload.sessionPath must be absolute", + ); + return { sessionPath }; + } + case "cursor": { + assertAllowedKeys(payload, new Set(["cursor"]), "payload"); + return { + ...(payload.cursor === undefined + ? {} + : { cursor: assertOptionalCursor(payload.cursor) }), + }; + } + case "message": { + assertAllowedKeys(payload, new Set(["message"]), "payload"); + return { + message: assertString(payload.message, "payload.message", { + maxLength: 32 * 1024, + }), + }; + } + case "extensionResponse": { + assertAllowedKeys(payload, new Set(["requestId", "response"]), "payload"); + const response = assertRecord(payload.response, "payload.response"); + if ( + !Object.hasOwn(response, "value") && + !Object.hasOwn(response, "confirmed") && + !Object.hasOwn(response, "cancelled") + ) { + throw new ProtocolError( + "invalid_message", + "payload.response must contain value, confirmed, or cancelled", + ); + } + return { + requestId: assertString(payload.requestId, "payload.requestId", { + maxLength: 128, + pattern: idPattern, + }), + response, + }; + } + case "model": { + assertAllowedKeys(payload, new Set(["provider", "modelId"]), "payload"); + return { + provider: assertString(payload.provider, "payload.provider", { + maxLength: 128, + }), + modelId: assertString(payload.modelId, "payload.modelId", { + maxLength: 512, + }), + }; + } + case "thinking": { + assertAllowedKeys(payload, new Set(["level"]), "payload"); + const level = assertString(payload.level, "payload.level", { + maxLength: 16, + }); + if (!thinkingLevels.has(level)) + throw new ProtocolError( + "invalid_message", + "payload.level is not supported", + ); + return { level }; + } + default: + throw new ProtocolError("invalid_message", "unsupported payload shape"); + } +} + +export function validateRequest(value) { + const request = assertRecord(value, "request"); + assertAllowedKeys( + request, + new Set(["version", "id", "op", "agentId", "payload"]), + "request", + ); + if (request.version !== PROTOCOL_VERSION) { + throw new ProtocolError( + "unsupported_version", + `version must be ${PROTOCOL_VERSION}`, + ); + } + + const id = assertString(request.id, "request.id", { + maxLength: 128, + pattern: idPattern, + }); + const op = assertString(request.op, "request.op", { maxLength: 64 }); + const operation = requestOperations.get(op); + if (!operation) + throw new ProtocolError( + "unsupported_operation", + `operation ${op} is not supported`, + ); + + let agentId; + if (operation.agent) { + agentId = assertString(request.agentId, "request.agentId", { + maxLength: 128, + pattern: idPattern, + }); + } else if (request.agentId !== undefined) { + throw new ProtocolError( + "invalid_message", + "request.agentId is not allowed for this operation", + ); + } + + const payload = validatePayload(operation.payload, request.payload); + return { + version: PROTOCOL_VERSION, + id, + op, + ...(agentId ? { agentId } : {}), + ...(payload === undefined ? {} : { payload }), + }; +} + +export function parseRequestFrame(frame, maxFrameBytes = MAX_FRAME_BYTES) { + const bytes = Buffer.isBuffer(frame) + ? frame.length + : Buffer.byteLength(frame, "utf8"); + if (bytes > maxFrameBytes) + throw new ProtocolError( + "frame_too_large", + `frame exceeds ${maxFrameBytes} bytes`, + ); + const line = String(frame).endsWith("\r") + ? String(frame).slice(0, -1) + : String(frame); + if (line.length === 0) + throw new ProtocolError("invalid_message", "frame cannot be empty"); + + let parsed; + try { + parsed = JSON.parse(line); + } catch { + throw new ProtocolError("invalid_json", "frame must contain valid JSON"); + } + return validateRequest(parsed); +} + +export function validateEvent(value) { + const event = assertRecord(value, "event"); + assertAllowedKeys( + event, + new Set(["version", "seq", "type", "agentId", "data"]), + "event", + ); + if (event.version !== PROTOCOL_VERSION) + throw new ProtocolError( + "unsupported_version", + `version must be ${PROTOCOL_VERSION}`, + ); + if (!Number.isSafeInteger(event.seq) || event.seq < 1) + throw new ProtocolError( + "invalid_message", + "event.seq must be a positive integer", + ); + const type = assertString(event.type, "event.type", { maxLength: 64 }); + if (!eventTypes.has(type)) + throw new ProtocolError( + "unsupported_event", + `event ${type} is not supported`, + ); + return { + version: PROTOCOL_VERSION, + seq: event.seq, + type, + agentId: assertString(event.agentId, "event.agentId", { + maxLength: 128, + pattern: idPattern, + }), + data: assertRecord(event.data, "event.data"), + }; +} + +export function successResponse(id, result = {}) { + return { version: PROTOCOL_VERSION, id, ok: true, result }; +} + +export function errorResponse(id, error) { + const code = error instanceof ProtocolError ? error.code : "internal_error"; + const message = + error instanceof Error ? error.message : "internal bridge error"; + return { + version: PROTOCOL_VERSION, + ...(id ? { id } : {}), + ok: false, + error: { code, message }, + }; +} + +export function encodeFrame(value) { + return `${JSON.stringify(value)}\n`; +} diff --git a/systemd/pi-status-bridge.service b/systemd/pi-status-bridge.service new file mode 100644 index 0000000..db96898 --- /dev/null +++ b/systemd/pi-status-bridge.service @@ -0,0 +1,12 @@ +[Unit] +Description=Pi Status Bridge home agent +After=graphical-session.target + +[Service] +Type=simple +ExecStart=/home/alex/.npm-global/bin/pi-status-bridge --session-root %h/.local/state/pi-status-bridge/sessions +Restart=on-failure +RestartSec=3 + +[Install] +WantedBy=default.target diff --git a/test/agent-recovery.test.js b/test/agent-recovery.test.js new file mode 100644 index 0000000..ba9703a --- /dev/null +++ b/test/agent-recovery.test.js @@ -0,0 +1,46 @@ +import assert from "node:assert/strict"; +import { mkdir, mkdtemp } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { createAgentRegistry } from "../src/bridge/agent-registry.js"; + +test("recovers an exited agent with its last Pi session reference", async () => { + const root = await mkdtemp(join(tmpdir(), "pi-status-bridge-recovery-")); + const home = join(root, "home"); + const sessionRoot = join(root, "sessions"); + await mkdir(home); + const sessionFile = join(sessionRoot, "pi-session.jsonl"); + const calls = []; + const registry = createAgentRegistry({ + homeWorktree: home, + sessionRoot, + recoveryDelayForAttempt: () => 0, + sleep: async () => {}, + startAdapter: (options) => { + const adapter = { + send: async () => ({ + type: "response", + success: true, + data: { sessionFile }, + }), + respondToExtension: () => {}, + stop: async () => {}, + }; + calls.push({ options, adapter }); + return adapter; + }, + }); + + const agent = await registry.start(); + calls[0].options.onError({ code: "child_exited", message: "crashed" }); + await registry.waitForRecovery(agent.id); + + assert.equal(calls.length, 2); + assert.equal(calls[1].options.sessionPath, sessionFile); + assert.deepEqual( + registry.eventsAfter(agent.id, 0).map((event) => event.type), + ["agent_state", "recovery", "recovery", "agent_state"], + ); + await registry.stop(); +}); diff --git a/test/agent-registry.test.js b/test/agent-registry.test.js new file mode 100644 index 0000000..1328c20 --- /dev/null +++ b/test/agent-registry.test.js @@ -0,0 +1,240 @@ +import assert from "node:assert/strict"; +import { mkdir, mkdtemp, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { createAgentRegistry } from "../src/bridge/agent-registry.js"; + +async function createWorktrees() { + const root = await mkdtemp(join(tmpdir(), "pi-status-bridge-worktrees-")); + const home = join(root, "home"); + const feature = join(root, "feature"); + await Promise.all([mkdir(home), mkdir(feature)]); + return { root, home, feature, sessionRoot: join(root, "sessions") }; +} + +function createAdapterFactory() { + const calls = []; + return { + calls, + startAdapter: (options) => { + const adapter = { + sent: [], + extensionResponses: [], + stopped: false, + send(command) { + this.sent.push(command); + return Promise.resolve({ + type: "response", + command: command.type, + success: true, + }); + }, + respondToExtension(requestId, response) { + this.extensionResponses.push({ requestId, response }); + }, + async stop() { + this.stopped = true; + }, + }; + calls.push({ options, adapter }); + return adapter; + }, + }; +} + +test("starts only the home agent and creates other worktree agents on explicit selection", async () => { + const worktrees = await createWorktrees(); + const fixture = createAdapterFactory(); + const registry = createAgentRegistry({ + homeWorktree: worktrees.home, + sessionRoot: worktrees.sessionRoot, + startAdapter: fixture.startAdapter, + }); + + const home = await registry.start(); + assert.equal(registry.listAgents().length, 1); + assert.equal(home.worktreePath, worktrees.home); + + const feature = await registry.selectWorktree(worktrees.feature); + const selectedAgain = await registry.selectWorktree( + `${worktrees.feature}/../feature`, + ); + assert.equal(feature.id, selectedAgain.id); + assert.notEqual(home.id, feature.id); + assert.deepEqual( + fixture.calls.map(({ options }) => options.cwd), + [worktrees.home, worktrees.feature], + ); + assert.notEqual(home.sessionDir, feature.sessionDir); + + await registry.stop(); + assert.ok(fixture.calls.every(({ adapter }) => adapter.stopped)); +}); + +test("catalogues managed directories and exposes only their sessions", async () => { + const worktrees = await createWorktrees(); + const fixture = createAdapterFactory(); + const registry = createAgentRegistry({ + homeWorktree: worktrees.home, + sessionRoot: worktrees.sessionRoot, + startAdapter: fixture.startAdapter, + }); + await registry.start(); + const feature = await registry.selectWorktree(worktrees.feature); + const olderSession = join(feature.sessionDir, "older.jsonl"); + const newestSession = join(feature.sessionDir, "newest.jsonl"); + await writeFile( + olderSession, + [ + JSON.stringify({ + type: "session", + id: "older", + timestamp: "2025-01-01T00:00:00.000Z", + cwd: worktrees.feature, + }), + JSON.stringify({ + type: "message", + message: { role: "user", content: "Older task" }, + }), + ].join("\n"), + ); + await writeFile( + newestSession, + [ + JSON.stringify({ + type: "session", + id: "newest", + timestamp: "2025-01-02T00:00:00.000Z", + cwd: worktrees.feature, + }), + JSON.stringify({ type: "session_info", name: "Newest work" }), + JSON.stringify({ + type: "message", + message: { role: "user", content: "Newest task" }, + }), + ].join("\n"), + ); + await writeFile( + join(feature.sessionDir, "bridge-agent.json"), + `${JSON.stringify({ sessionPath: newestSession, worktreePath: worktrees.feature })}\n`, + ); + + const sessions = await registry.listSessions(feature.id); + assert.deepEqual( + sessions.map(({ id, name, isCurrent }) => ({ id, name, isCurrent })), + [ + { id: "newest", name: "Newest work", isCurrent: false }, + { id: "older", name: undefined, isCurrent: false }, + ], + ); + await registry.route(feature.id, "switch_session", { + sessionPath: newestSession, + }); + await registry.route(feature.id, "new_session"); + assert.deepEqual( + fixture.calls[1].adapter.sent.map((command) => command.type), + ["get_state", "switch_session", "get_state", "new_session", "get_state"], + ); + await assert.rejects( + registry.route(feature.id, "switch_session", { + sessionPath: olderSession.replace("older", "../outside"), + }), + /ENOENT|belong/, + ); + await registry.stop(); + + const restored = createAgentRegistry({ + homeWorktree: worktrees.home, + sessionRoot: worktrees.sessionRoot, + startAdapter: createAdapterFactory().startAdapter, + }); + await restored.start(); + assert.deepEqual(await restored.listDirectories(), [ + { worktreePath: worktrees.feature, state: "inactive" }, + { + worktreePath: worktrees.home, + state: "idle", + agentId: restored.listAgents()[0].id, + }, + ]); + await restored.stop(); +}); + +test("routes commands by explicit agent ID and replays only events after the cursor", async () => { + const worktrees = await createWorktrees(); + const fixture = createAdapterFactory(); + const registry = createAgentRegistry({ + homeWorktree: worktrees.home, + sessionRoot: worktrees.sessionRoot, + startAdapter: fixture.startAdapter, + }); + const agent = await registry.start(); + const [{ options, adapter }] = fixture.calls; + + const response = await registry.route(agent.id, "prompt", { + message: "Stay in this worktree", + }); + assert.deepEqual(response, { + type: "response", + command: "prompt", + success: true, + }); + const stats = await registry.route(agent.id, "get_session_stats"); + assert.deepEqual(stats, { + type: "response", + command: "get_session_stats", + success: true, + }); + const commands = await registry.route(agent.id, "get_commands"); + assert.deepEqual(commands, { + type: "response", + command: "get_commands", + success: true, + }); + assert.deepEqual(adapter.sent, [ + { type: "get_state" }, + { type: "prompt", message: "Stay in this worktree" }, + { type: "get_session_stats" }, + { type: "get_commands" }, + ]); + + options.onEvent({ + type: "agent_state", + data: { state: "streaming" }, + }); + await registry.route(agent.id, "submit_prompt", { + message: "After this turn", + }); + assert.deepEqual(adapter.sent.at(-1), { + type: "follow_up", + message: "After this turn", + }); + + options.onEvent({ + type: "stream", + data: { event: { type: "message_update" } }, + }); + options.onEvent({ type: "queue", data: { event: { type: "queue_update" } } }); + assert.deepEqual( + registry.eventsAfter(agent.id, 0).map(({ seq, type }) => ({ seq, type })), + [ + { seq: 1, type: "agent_state" }, + { seq: 2, type: "stream" }, + { seq: 3, type: "queue" }, + ], + ); + assert.deepEqual( + registry.eventsAfter(agent.id, 1).map(({ seq, type }) => ({ seq, type })), + [ + { seq: 2, type: "stream" }, + { seq: 3, type: "queue" }, + ], + ); + + await assert.rejects( + registry.route("missing-agent", "abort"), + /unknown agent/, + ); + await registry.stop(); +}); diff --git a/test/bridge-service.test.js b/test/bridge-service.test.js new file mode 100644 index 0000000..d187a90 --- /dev/null +++ b/test/bridge-service.test.js @@ -0,0 +1,113 @@ +import assert from "node:assert/strict"; +import { EventEmitter } from "node:events"; +import { mkdir, mkdtemp } from "node:fs/promises"; +import { createConnection } from "node:net"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { startBridgeService } from "../src/bridge/service.js"; + +function connect(socketPath) { + return new Promise((resolve, reject) => { + const socket = createConnection(socketPath); + socket.once("connect", () => resolve(socket)); + socket.once("error", reject); + }); +} + +function request(socket, frame) { + return new Promise((resolve, reject) => { + let buffer = ""; + const onData = (chunk) => { + buffer += chunk; + const newline = buffer.indexOf("\n"); + if (newline === -1) return; + socket.off("data", onData); + try { + resolve(JSON.parse(buffer.slice(0, newline))); + } catch (error) { + reject(error); + } + }; + socket.on("data", onData); + socket.once("error", reject); + socket.write(`${JSON.stringify(frame)}\n`); + }); +} + +function createAdapterFactory() { + const calls = []; + return { + calls, + startAdapter: (options) => { + const adapter = new EventEmitter(); + adapter.sent = []; + adapter.send = async (command) => { + adapter.sent.push(command); + return { + type: "response", + command: command.type, + success: true, + data: { command }, + }; + }; + adapter.respondToExtension = () => {}; + adapter.stop = async () => {}; + calls.push({ options, adapter }); + return adapter; + }, + }; +} + +test("starts the home agent and dispatches local protocol requests to the selected worktree", async () => { + const root = await mkdtemp(join(tmpdir(), "pi-status-bridge-service-")); + const home = join(root, "home"); + const feature = join(root, "feature"); + await Promise.all([mkdir(home), mkdir(feature)]); + const fixture = createAdapterFactory(); + const service = await startBridgeService({ + homeWorktree: home, + runtimeDir: join(root, "runtime"), + sessionRoot: join(root, "sessions"), + startAdapter: fixture.startAdapter, + }); + const socket = await connect(service.socketPath); + + try { + const agents = await request(socket, { + version: "v1", + id: "list-1", + op: "list_agents", + }); + assert.equal(agents.result.agents.length, 1); + assert.equal(agents.result.agents[0].worktreePath, home); + + const selected = await request(socket, { + version: "v1", + id: "select-1", + op: "select_agent", + payload: { worktreePath: feature }, + }); + const featureAgent = selected.result.agent; + const prompt = await request(socket, { + version: "v1", + id: "prompt-1", + op: "prompt", + agentId: featureAgent.id, + payload: { message: "Use this worktree" }, + }); + + assert.equal(prompt.ok, true); + assert.deepEqual( + fixture.calls.map(({ options }) => options.cwd), + [home, feature], + ); + assert.deepEqual(fixture.calls[1].adapter.sent, [ + { type: "get_state" }, + { type: "prompt", message: "Use this worktree" }, + ]); + } finally { + socket.destroy(); + await service.close(); + } +}); diff --git a/test/client-cli.test.js b/test/client-cli.test.js new file mode 100644 index 0000000..208f58f --- /dev/null +++ b/test/client-cli.test.js @@ -0,0 +1,45 @@ +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { mkdir, mkdtemp } from "node:fs/promises"; +import { promisify } from "node:util"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { startBridgeService } from "../src/bridge/service.js"; + +const execFileAsync = promisify(execFile); + +test("executes an approved bridge request through the local client CLI", async () => { + const root = await mkdtemp(join(tmpdir(), "pi-status-bridge-cli-")); + const home = join(root, "home"); + await mkdir(home); + const service = await startBridgeService({ + homeWorktree: home, + runtimeDir: join(root, "runtime"), + sessionRoot: join(root, "sessions"), + startAdapter: () => ({ + send: async () => ({ type: "response", success: true }), + respondToExtension: () => {}, + stop: async () => {}, + }), + }); + + try { + const { stdout } = await execFileAsync( + process.execPath, + [ + "src/client/cli.js", + "--socket", + service.socketPath, + "request", + '{"op":"list_agents"}', + ], + { cwd: process.cwd() }, + ); + const result = JSON.parse(stdout); + assert.equal(result.agents.length, 1); + assert.equal(result.agents[0].worktreePath, home); + } finally { + await service.close(); + } +}); diff --git a/test/daemon-and-relay.test.js b/test/daemon-and-relay.test.js new file mode 100644 index 0000000..be4c4c4 --- /dev/null +++ b/test/daemon-and-relay.test.js @@ -0,0 +1,73 @@ +import assert from "node:assert/strict"; +import { mkdir, mkdtemp } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { startBridgeDaemon } from "../src/bridge/daemon.js"; +import { createNoctaliaStateRelay } from "../src/client/noctalia-relay.js"; + +test("starts a bridge daemon around the local bridge service", async () => { + const root = await mkdtemp(join(tmpdir(), "pi-status-bridge-daemon-")); + const home = join(root, "home"); + await mkdir(home); + const daemon = await startBridgeDaemon({ + homeWorktree: home, + runtimeDir: join(root, "runtime"), + sessionRoot: join(root, "sessions"), + startAdapter: () => ({ + send: async () => ({ type: "response", success: true }), + respondToExtension: () => {}, + stop: async () => {}, + }), + }); + try { + assert.match(daemon.socketPath, /bridge\.sock$/); + assert.equal(daemon.listAgents()[0].worktreePath, home); + } finally { + await daemon.close(); + } +}); + +test("relays only the selected agent's bridge state into a presentation update", async () => { + let listener; + const updates = []; + const relay = createNoctaliaStateRelay({ + client: { + request: async () => ({ data: { isStreaming: false } }), + subscribe: async (_agentId, _cursor, callback) => { + listener = callback; + return () => {}; + }, + }, + agent: { id: "agent-1", worktreePath: "/worktrees/feature" }, + onState: (state) => updates.push(state), + }); + + await relay.start(); + listener({ type: "agent_state", data: { state: "streaming" } }); + listener({ + type: "queue", + data: { event: { steering: ["focus"], followUp: [] } }, + }); + + assert.deepEqual(updates, [ + { + state: "idle", + projectLabel: "feature", + attentionCount: 0, + detail: "Idle", + }, + { + state: "streaming", + projectLabel: "feature", + attentionCount: 0, + detail: "Streaming", + }, + { + state: "streaming", + projectLabel: "feature", + attentionCount: 1, + detail: "Streaming", + }, + ]); +}); diff --git a/test/instance-lock.test.js b/test/instance-lock.test.js new file mode 100644 index 0000000..aa9ce7e --- /dev/null +++ b/test/instance-lock.test.js @@ -0,0 +1,106 @@ +import assert from "node:assert/strict"; +import { mkdtemp, readFile, stat, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { + BridgeAlreadyRunningError, + BridgeLockError, + acquireBridgeLock, + lockMode, +} from "../src/bridge/instance-lock.js"; + +async function paths() { + const directory = await mkdtemp(join(tmpdir(), "pi-status-bridge-lock-")); + return { + lockPath: join(directory, "bridge.lock"), + socketPath: join(directory, "bridge.sock"), + }; +} + +test("acquires an owner-only lock and releases only its own token", async () => { + const { lockPath, socketPath } = await paths(); + const lock = await acquireBridgeLock({ + lockPath, + socketPath, + pid: 101, + now: () => new Date("2026-01-02T03:04:05.000Z"), + }); + const lockContents = await readFile(lockPath, "utf8"); + let metadata; + try { + metadata = JSON.parse(lockContents); + } catch (error) { + assert.fail( + error instanceof Error ? error.message : "lock file was not JSON", + ); + } + + const lockStats = await stat(lockPath); + assert.equal(metadata.pid, 101); + assert.equal(metadata.socketPath, socketPath); + assert.equal(lockStats.mode & 0o777, 0o600); + assert.equal(lockMode, 0o600); + assert.equal(await lock.release(), true); + assert.equal(await lock.release(), false); +}); + +test("refuses to replace a live owner", async () => { + const { lockPath, socketPath } = await paths(); + await writeFile( + lockPath, + JSON.stringify({ + pid: 202, + socketPath, + startedAt: "2026-01-02T03:04:05.000Z", + token: "live-owner", + }), + ); + + await assert.rejects( + acquireBridgeLock({ + lockPath, + socketPath, + pid: 303, + processAlive: (pid) => pid === 202, + }), + BridgeAlreadyRunningError, + ); +}); + +test("recovers only a proven-stale lock", async () => { + const { lockPath, socketPath } = await paths(); + await writeFile( + lockPath, + JSON.stringify({ + pid: 404, + socketPath, + startedAt: "2026-01-02T03:04:05.000Z", + token: "stale-owner", + }), + ); + + const lock = await acquireBridgeLock({ + lockPath, + socketPath, + pid: 505, + processAlive: () => false, + }); + assert.equal(lock.metadata.pid, 505); + await lock.release(); +}); + +test("does not reclaim a malformed lock", async () => { + const { lockPath, socketPath } = await paths(); + await writeFile(lockPath, "broken"); + + await assert.rejects( + acquireBridgeLock({ + lockPath, + socketPath, + pid: 606, + processAlive: () => false, + }), + BridgeLockError, + ); +}); diff --git a/test/local-client.test.js b/test/local-client.test.js new file mode 100644 index 0000000..9096638 --- /dev/null +++ b/test/local-client.test.js @@ -0,0 +1,59 @@ +import assert from "node:assert/strict"; +import { mkdir, mkdtemp } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { connectLocalClient } from "../src/client/local-client.js"; +import { startBridgeService } from "../src/bridge/service.js"; + +test("uses only the approved local protocol for requests and live event subscriptions", async () => { + const root = await mkdtemp(join(tmpdir(), "pi-status-bridge-client-")); + const home = join(root, "home"); + await mkdir(home); + const starts = []; + const service = await startBridgeService({ + homeWorktree: home, + runtimeDir: join(root, "runtime"), + sessionRoot: join(root, "sessions"), + startAdapter: (options) => { + const adapter = { + send: async () => ({ type: "response", success: true }), + respondToExtension: () => {}, + stop: async () => {}, + }; + starts.push({ options, adapter }); + return adapter; + }, + }); + const client = await connectLocalClient({ socketPath: service.socketPath }); + + try { + const agents = await client.request("list_agents"); + assert.equal(agents.agents.length, 1); + const events = []; + const unsubscribe = await client.subscribe( + agents.agents[0].id, + 0, + (event) => events.push(event), + ); + starts[0].options.onEvent({ + type: "queue", + data: { event: { type: "queue_update" } }, + }); + await new Promise((resolve) => setTimeout(resolve, 10)); + + assert.deepEqual(events, [ + { + version: "v1", + seq: 1, + type: "queue", + agentId: agents.agents[0].id, + data: { event: { type: "queue_update" } }, + }, + ]); + unsubscribe(); + } finally { + await client.close(); + await service.close(); + } +}); diff --git a/test/noctalia-ipc.test.js b/test/noctalia-ipc.test.js new file mode 100644 index 0000000..1b49a6e --- /dev/null +++ b/test/noctalia-ipc.test.js @@ -0,0 +1,34 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { publishNoctaliaState } from "../src/client/noctalia-ipc.js"; + +test("publishes presentation state through Noctalia's documented IPC command", async () => { + const calls = []; + await publishNoctaliaState( + { + state: "streaming", + projectLabel: "feature", + attentionCount: 2, + detail: "Streaming", + }, + { execute: async (command, args) => calls.push({ command, args }) }, + ); + + assert.deepEqual(calls, [ + { + command: "qs", + args: [ + "-c", + "noctalia-shell", + "ipc", + "call", + "plugin:pi-status-bridge", + "setBridgeState", + "streaming", + "feature", + "2", + "Streaming", + ], + }, + ]); +}); diff --git a/test/noctalia-plugin.test.js b/test/noctalia-plugin.test.js new file mode 100644 index 0000000..c8312a0 --- /dev/null +++ b/test/noctalia-plugin.test.js @@ -0,0 +1,34 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; + +test("ships a Noctalia plugin with compact bar and presentation-only panel entry points", async () => { + const manifestText = await readFile( + "noctalia-plugin/pi-status-bridge/manifest.json", + "utf8", + ); + let manifest; + try { + manifest = JSON.parse(manifestText); + } catch (error) { + assert.fail( + error instanceof Error ? error.message : "manifest was not valid JSON", + ); + } + const [bar, panel, main] = await Promise.all([ + readFile("noctalia-plugin/pi-status-bridge/BarWidget.qml", "utf8"), + readFile("noctalia-plugin/pi-status-bridge/Panel.qml", "utf8"), + readFile("noctalia-plugin/pi-status-bridge/Main.qml", "utf8"), + ]); + + assert.equal(manifest.id, "pi-status-bridge"); + assert.deepEqual(manifest.entryPoints, { + main: "Main.qml", + barWidget: "BarWidget.qml", + panel: "Panel.qml", + }); + assert.match(bar, /pluginApi\.togglePanel\(root\.screen, visualCapsule\)/); + assert.match(panel, /allowAttach: true/); + assert.match(main, /IpcHandler/); + assert.match(main, /setBridgeState/); +}); diff --git a/test/noctalia-v5-plugin.test.js b/test/noctalia-v5-plugin.test.js new file mode 100644 index 0000000..7db3cba --- /dev/null +++ b/test/noctalia-v5-plugin.test.js @@ -0,0 +1,21 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; + +test("ships a v5 Noctalia compatibility launcher instead of a primary panel", async () => { + const [manifest, bridge] = await Promise.all([ + readFile("noctalia-v5-plugin/pi-status-bridge/plugin.toml", "utf8"), + readFile("noctalia-v5-plugin/pi-status-bridge/bridge.luau", "utf8"), + ]); + + assert.doesNotMatch(manifest, /\[\[panel\]\]/); + assert.match(manifest, /entry = "bridge\.luau"/); + assert.match(bridge, /PI_STATUS_UI_BINARY/); + assert.match(bridge, /pi-status-ui/); + assert.match(bridge, /noctalia\.runAsync/); + assert.match(bridge, /setsid -f env TMPDIR=\/tmp/); + assert.match(bridge, /pi-status-ui\.log/); + assert.doesNotMatch(bridge, /togglePanel/); + assert.match(bridge, /Pi reconnecting/); + assert.match(bridge, /discover_home_agent\(socket\)/); +}); diff --git a/test/pi-rpc-adapter.test.js b/test/pi-rpc-adapter.test.js new file mode 100644 index 0000000..66916d4 --- /dev/null +++ b/test/pi-rpc-adapter.test.js @@ -0,0 +1,168 @@ +import assert from "node:assert/strict"; +import { EventEmitter } from "node:events"; +import { PassThrough } from "node:stream"; +import test from "node:test"; +import { startPiRpcAdapter } from "../src/bridge/pi-rpc-adapter.js"; + +class FakePiChild extends EventEmitter { + constructor() { + super(); + this.stdin = new PassThrough(); + this.stdout = new PassThrough(); + this.stderr = new PassThrough(); + this.killed = false; + } + + kill(signal) { + this.killed = true; + this.emit("exit", null, signal); + return true; + } +} + +function parseSent(stdin) { + try { + return stdin + .split("\n") + .filter(Boolean) + .map((line) => JSON.parse(line)); + } catch (error) { + throw new Error( + `fake child received invalid JSON: ${error instanceof Error ? error.message : "unknown error"}`, + ); + } +} + +function createFixture() { + const child = new FakePiChild(); + const calls = []; + let stdin = ""; + child.stdin.on("data", (chunk) => { + stdin += chunk; + }); + return { + child, + calls, + spawn: (command, args, options) => { + calls.push({ command, args, options }); + return child; + }, + sent: () => parseSent(stdin), + }; +} + +test("starts Pi in RPC mode and correlates a command response", async () => { + const fixture = createFixture(); + const events = []; + const adapter = startPiRpcAdapter({ + cwd: "/workspace/home", + sessionDir: "/workspace/sessions", + spawnProcess: fixture.spawn, + onEvent: (event) => events.push(event), + }); + + const response = adapter.send({ + type: "prompt", + message: "Implement the bridge", + }); + const [command] = fixture.sent(); + assert.deepEqual(fixture.calls[0], { + command: "pi", + args: ["--mode", "rpc", "--session-dir", "/workspace/sessions"], + options: { cwd: "/workspace/home", stdio: ["pipe", "pipe", "pipe"] }, + }); + assert.equal(command.type, "prompt"); + assert.equal(command.message, "Implement the bridge"); + assert.match(command.id, /^bridge-/); + + fixture.child.stdout.write('{"type":"agent_start"}\n'); + fixture.child.stdout.write( + `${JSON.stringify({ type: "response", id: command.id, command: "prompt", success: true })}\n`, + ); + + assert.deepEqual(await response, { + type: "response", + id: command.id, + command: "prompt", + success: true, + }); + assert.deepEqual(events, [ + { + seq: 1, + type: "agent_state", + data: { state: "streaming", event: { type: "agent_start" } }, + }, + ]); + await adapter.stop(); +}); + +test("requests Pi session statistics for context and token status", async () => { + const fixture = createFixture(); + const adapter = startPiRpcAdapter({ + cwd: "/workspace/home", + sessionDir: "/workspace/sessions", + spawnProcess: fixture.spawn, + }); + + const pending = adapter.send({ type: "get_session_stats" }); + const [command] = fixture.sent(); + assert.equal(command.type, "get_session_stats"); + fixture.child.stdout.write( + `${JSON.stringify({ type: "response", id: command.id, command: "get_session_stats", success: true, data: { contextUsage: { tokens: 32000, contextWindow: 200000 } } })}\n`, + ); + const stats = await pending; + assert.equal(stats.data.contextUsage.tokens, 32000); + await adapter.stop(); +}); + +test("forwards extension responses without replacing Pi's request ID", async () => { + const fixture = createFixture(); + const events = []; + const adapter = startPiRpcAdapter({ + cwd: "/workspace/home", + sessionDir: "/workspace/sessions", + spawnProcess: fixture.spawn, + onEvent: (event) => events.push(event), + }); + + const extensionRequest = { + type: "extension_ui_request", + id: "extension-request-1", + method: "confirm", + title: "Keep U+2028 here", + message: "Approve?", + }; + fixture.child.stdout.write(`${JSON.stringify(extensionRequest)}\n`); + adapter.respondToExtension("extension-request-1", { confirmed: false }); + + assert.deepEqual(events, [ + { seq: 1, type: "extension_ui_request", data: { event: extensionRequest } }, + ]); + assert.deepEqual(fixture.sent(), [ + { + type: "extension_ui_response", + id: "extension-request-1", + confirmed: false, + }, + ]); + await adapter.stop(); +}); + +test("reports invalid child output and rejects pending commands on child exit", async () => { + const fixture = createFixture(); + const errors = []; + const adapter = startPiRpcAdapter({ + cwd: "/workspace/home", + sessionDir: "/workspace/sessions", + spawnProcess: fixture.spawn, + onError: (error) => errors.push(error), + }); + + const pending = adapter.send({ type: "get_state" }); + fixture.child.stdout.write("not-json\n"); + fixture.child.emit("exit", 1, null); + + await assert.rejects(pending, /exited before responding/); + assert.equal(errors[0].code, "invalid_json"); + await adapter.stop(); +}); diff --git a/test/pi-rpc-events.test.js b/test/pi-rpc-events.test.js new file mode 100644 index 0000000..7a99240 --- /dev/null +++ b/test/pi-rpc-events.test.js @@ -0,0 +1,96 @@ +import assert from "node:assert/strict"; +import { EventEmitter } from "node:events"; +import { PassThrough } from "node:stream"; +import test from "node:test"; +import { startPiRpcAdapter } from "../src/bridge/pi-rpc-adapter.js"; + +function createChild() { + const child = new EventEmitter(); + child.stdin = new PassThrough(); + child.stdout = new PassThrough(); + child.stderr = new PassThrough(); + child.kill = (signal) => { + child.emit("exit", null, signal); + return true; + }; + return child; +} + +test("normalizes streaming, tool, and queue events in child output order", async () => { + const child = createChild(); + const events = []; + const adapter = startPiRpcAdapter({ + cwd: "/workspace/home", + sessionDir: "/workspace/sessions", + spawnProcess: () => child, + onEvent: (event) => events.push(event), + }); + + const piEvents = [ + { + type: "message_update", + message: { role: "assistant" }, + assistantMessageEvent: { type: "text_delta", delta: "Hello" }, + }, + { + type: "tool_execution_start", + toolCallId: "tool-1", + toolName: "bash", + args: { command: "pwd" }, + }, + { + type: "tool_execution_end", + toolCallId: "todo-1", + toolName: "todo", + result: { + details: { + tasks: [{ id: 1, subject: "Ship pane", status: "in_progress" }], + }, + }, + isError: false, + }, + { type: "queue_update", steering: ["Refocus"], followUp: [] }, + ]; + child.stdout.write(JSON.stringify(piEvents[0]).slice(0, 30)); + child.stdout.write( + `${JSON.stringify(piEvents[0]).slice(30)}\n${JSON.stringify(piEvents[1])}\n${JSON.stringify(piEvents[2])}\n${JSON.stringify(piEvents[3])}\n`, + ); + + assert.deepEqual(events, [ + { seq: 1, type: "stream", data: { event: piEvents[0] } }, + { seq: 2, type: "tool", data: { event: piEvents[1] } }, + { seq: 3, type: "tool", data: { event: piEvents[2] } }, + { seq: 4, type: "queue", data: { event: piEvents[3] } }, + ]); + await adapter.stop(); +}); + +test("marks Pi idle only after it settles and preserves extension errors as diagnostics", async () => { + const child = createChild(); + const events = []; + const adapter = startPiRpcAdapter({ + cwd: "/workspace/home", + sessionDir: "/workspace/sessions", + spawnProcess: () => child, + onEvent: (event) => events.push(event), + }); + const piEvents = [ + { type: "agent_start" }, + { type: "agent_end", willRetry: false }, + { type: "agent_settled" }, + { type: "extension_error", error: "non-fatal extension failure" }, + ]; + child.stdout.write( + `${piEvents.map((event) => JSON.stringify(event)).join("\n")}\n`, + ); + assert.deepEqual( + events.map((event) => ({ type: event.type, state: event.data.state })), + [ + { type: "agent_state", state: "streaming" }, + { type: "transcript", state: undefined }, + { type: "agent_state", state: "idle" }, + { type: "transcript", state: undefined }, + ], + ); + await adapter.stop(); +}); diff --git a/test/protocol.test.js b/test/protocol.test.js new file mode 100644 index 0000000..8cb72cb --- /dev/null +++ b/test/protocol.test.js @@ -0,0 +1,198 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + MAX_FRAME_BYTES, + ProtocolError, + parseRequestFrame, + validateEvent, +} from "../src/protocol/index.js"; + +const request = (value) => + JSON.stringify({ version: "v1", id: "request-1", ...value }); + +test("accepts a command scoped to its selected agent", () => { + assert.deepEqual( + parseRequestFrame( + request({ + op: "prompt", + agentId: "agent-main", + payload: { message: "implement the protocol" }, + }), + ), + { + version: "v1", + id: "request-1", + op: "prompt", + agentId: "agent-main", + payload: { message: "implement the protocol" }, + }, + ); +}); + +test("accepts an agent transcript request without a synthetic cursor", () => { + assert.deepEqual( + parseRequestFrame(request({ op: "get_transcript", agentId: "agent-main" })), + { + version: "v1", + id: "request-1", + op: "get_transcript", + agentId: "agent-main", + }, + ); +}); + +test("accepts bridge-managed directory and session operations", () => { + assert.deepEqual(parseRequestFrame(request({ op: "list_directories" })), { + version: "v1", + id: "request-1", + op: "list_directories", + }); + assert.deepEqual( + parseRequestFrame(request({ op: "list_sessions", agentId: "agent-main" })), + { + version: "v1", + id: "request-1", + op: "list_sessions", + agentId: "agent-main", + }, + ); + assert.deepEqual( + parseRequestFrame(request({ op: "new_session", agentId: "agent-main" })), + { + version: "v1", + id: "request-1", + op: "new_session", + agentId: "agent-main", + }, + ); + assert.deepEqual( + parseRequestFrame( + request({ + op: "switch_session", + agentId: "agent-main", + payload: { sessionPath: "/sessions/session.jsonl" }, + }), + ), + { + version: "v1", + id: "request-1", + op: "switch_session", + agentId: "agent-main", + payload: { sessionPath: "/sessions/session.jsonl" }, + }, + ); + assert.throws( + () => + parseRequestFrame( + request({ + op: "switch_session", + agentId: "agent-main", + payload: { sessionPath: "relative.jsonl" }, + }), + ), + /must be absolute/, + ); +}); + +test("accepts an agent session-statistics request", () => { + assert.deepEqual( + parseRequestFrame( + request({ op: "get_session_stats", agentId: "agent-main" }), + ), + { + version: "v1", + id: "request-1", + op: "get_session_stats", + agentId: "agent-main", + }, + ); +}); + +test("accepts a default composer submit request", () => { + assert.deepEqual( + parseRequestFrame( + request({ + op: "submit_prompt", + agentId: "agent-main", + payload: { message: "Continue" }, + }), + ), + { + version: "v1", + id: "request-1", + op: "submit_prompt", + agentId: "agent-main", + payload: { message: "Continue" }, + }, + ); +}); + +test("accepts a selected agent get_commands request", () => { + assert.deepEqual( + parseRequestFrame(request({ op: "get_commands", agentId: "agent-main" })), + { + version: "v1", + id: "request-1", + op: "get_commands", + agentId: "agent-main", + }, + ); +}); + +test("rejects malformed, unknown, and oversized frames", () => { + assert.throws(() => parseRequestFrame("not json"), ProtocolError); + assert.throws( + () => parseRequestFrame(request({ op: "teleport" })), + /not supported/, + ); + assert.throws( + () => parseRequestFrame("x".repeat(MAX_FRAME_BYTES + 1)), + /frame exceeds/, + ); +}); + +test("rejects cross-worktree routing fields on agent commands", () => { + assert.throws( + () => + parseRequestFrame( + request({ + op: "prompt", + agentId: "agent-main", + payload: { message: "wrong worktree", worktreePath: "/other" }, + }), + ), + /not supported/, + ); +}); + +test("requires explicit absolute worktree selection", () => { + assert.throws( + () => + parseRequestFrame( + request({ + op: "select_agent", + payload: { worktreePath: "relative/project" }, + }), + ), + /must be absolute/, + ); +}); + +test("normalizes sequenced bridge events", () => { + assert.deepEqual( + validateEvent({ + version: "v1", + seq: 2, + type: "extension_ui_request", + agentId: "agent-main", + data: { id: "extension-1", method: "confirm" }, + }), + { + version: "v1", + seq: 2, + type: "extension_ui_request", + agentId: "agent-main", + data: { id: "extension-1", method: "confirm" }, + }, + ); +}); diff --git a/test/recovery-supervisor.test.js b/test/recovery-supervisor.test.js new file mode 100644 index 0000000..60f438e --- /dev/null +++ b/test/recovery-supervisor.test.js @@ -0,0 +1,65 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createRecoverySupervisor } from "../src/bridge/recovery-supervisor.js"; + +test("restarts after bounded exponential delays and stops after exhaustion", async () => { + const delays = []; + const states = []; + let starts = 0; + const supervisor = createRecoverySupervisor({ + start: async () => ({ attempt: ++starts }), + maxAttempts: 2, + delayForAttempt: (attempt) => attempt * 100, + sleep: async (delay) => delays.push(delay), + onState: (state) => states.push(state), + }); + + assert.deepEqual(await supervisor.handleUnexpectedExit(), { attempt: 1 }); + assert.deepEqual(await supervisor.handleUnexpectedExit(), { attempt: 2 }); + assert.equal(await supervisor.handleUnexpectedExit(), undefined); + assert.deepEqual(delays, [100, 200]); + assert.deepEqual( + states.map(({ state, attempt }) => ({ state, attempt })), + [ + { state: "recovering", attempt: 1 }, + { state: "healthy", attempt: 1 }, + { state: "recovering", attempt: 2 }, + { state: "healthy", attempt: 2 }, + { state: "failed", attempt: 2 }, + ], + ); +}); + +test("restarts failed launches within the same bounded recovery budget", async () => { + let starts = 0; + const supervisor = createRecoverySupervisor({ + start: async () => { + starts += 1; + if (starts === 1) throw new Error("temporary spawn failure"); + return { attempt: starts }; + }, + maxAttempts: 2, + delayForAttempt: () => 0, + sleep: async () => {}, + }); + + assert.deepEqual(await supervisor.handleUnexpectedExit(), { attempt: 2 }); + assert.equal(starts, 2); +}); + +test("cancels scheduled recovery when stopped", async () => { + let resolveSleep; + const supervisor = createRecoverySupervisor({ + start: async () => assert.fail("must not restart after stop"), + delayForAttempt: () => 1, + sleep: () => + new Promise((resolve) => { + resolveSleep = resolve; + }), + }); + + const recovering = supervisor.handleUnexpectedExit(); + supervisor.stop(); + resolveSleep(); + assert.equal(await recovering, undefined); +}); diff --git a/test/runtime.test.js b/test/runtime.test.js new file mode 100644 index 0000000..09ee740 --- /dev/null +++ b/test/runtime.test.js @@ -0,0 +1,29 @@ +import assert from "node:assert/strict"; +import { mkdtemp, stat } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import test from "node:test"; +import { BridgeAlreadyRunningError } from "../src/bridge/instance-lock.js"; +import { + createRuntimePaths, + startBridgeServer, +} from "../src/bridge/runtime.js"; + +test("creates a private runtime directory and enforces one live bridge", async () => { + const runtimeDir = await mkdtemp(`${tmpdir()}/pi-status-bridge-runtime-`); + const paths = await createRuntimePaths(runtimeDir); + const runtimeStats = await stat(paths.directory); + assert.equal(runtimeStats.mode & 0o777, 0o700); + + const bridge = await startBridgeServer({ + runtimeDir, + handleRequest: async () => ({}), + }); + try { + await assert.rejects( + startBridgeServer({ runtimeDir, handleRequest: async () => ({}) }), + BridgeAlreadyRunningError, + ); + } finally { + await bridge.close(); + } +}); diff --git a/test/subscriptions.test.js b/test/subscriptions.test.js new file mode 100644 index 0000000..0e7e21c --- /dev/null +++ b/test/subscriptions.test.js @@ -0,0 +1,115 @@ +import assert from "node:assert/strict"; +import { mkdir, mkdtemp } from "node:fs/promises"; +import { createConnection } from "node:net"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { startBridgeService } from "../src/bridge/service.js"; + +function connect(socketPath) { + return new Promise((resolve, reject) => { + const socket = createConnection(socketPath); + socket.once("connect", () => resolve(socket)); + socket.once("error", reject); + }); +} + +function collectFrames(socket, count) { + return new Promise((resolve, reject) => { + const frames = []; + let buffer = ""; + const onData = (chunk) => { + buffer += chunk; + while (buffer.includes("\n")) { + const index = buffer.indexOf("\n"); + const line = buffer.slice(0, index); + buffer = buffer.slice(index + 1); + try { + frames.push(JSON.parse(line)); + } catch (error) { + reject(error); + return; + } + if (frames.length === count) { + socket.off("data", onData); + resolve(frames); + return; + } + } + }; + socket.on("data", onData); + socket.once("error", reject); + }); +} + +test("replays then streams subscribed agent events on the same local socket", async () => { + const root = await mkdtemp(join(tmpdir(), "pi-status-bridge-subscribe-")); + const home = join(root, "home"); + await mkdir(home); + const calls = []; + const service = await startBridgeService({ + homeWorktree: home, + runtimeDir: join(root, "runtime"), + sessionRoot: join(root, "sessions"), + startAdapter: (options) => { + const adapter = { + send: async () => ({ type: "response", success: true }), + respondToExtension: () => {}, + stop: async () => {}, + }; + calls.push({ options, adapter }); + return adapter; + }, + }); + calls[0].options.onEvent({ + type: "stream", + data: { event: { type: "message_update", delta: "replayed" } }, + }); + const socket = await connect(service.socketPath); + + try { + const frames = collectFrames(socket, 2); + socket.write( + `${JSON.stringify({ version: "v1", id: "subscribe-1", op: "subscribe", agentId: service.listAgents()[0].id, payload: {} })}\n`, + ); + setTimeout(() => { + calls[0].options.onEvent({ + type: "stream", + data: { event: { type: "message_update", delta: "live" } }, + }); + }, 10); + + assert.deepEqual(await frames, [ + { + version: "v1", + id: "subscribe-1", + ok: true, + result: { + events: [ + { + version: "v1", + seq: 1, + type: "stream", + agentId: service.listAgents()[0].id, + data: { event: { type: "message_update", delta: "replayed" } }, + }, + ], + }, + }, + { + version: "v1", + type: "event", + event: { + version: "v1", + seq: 2, + type: "stream", + agentId: service.listAgents()[0].id, + data: { event: { type: "message_update", delta: "live" } }, + }, + }, + ]); + } finally { + socket.destroy(); + await service.close(); + } +}); diff --git a/test/tauri-ui.test.js b/test/tauri-ui.test.js new file mode 100644 index 0000000..bba00b0 --- /dev/null +++ b/test/tauri-ui.test.js @@ -0,0 +1,214 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; + +test("renders every Pi RPC extension UI method without replying to fire-and-forget requests", async () => { + const source = await readFile("ui/src/App.tsx", "utf8"); + for (const method of [ + "select", + "confirm", + "input", + "editor", + "notify", + "setStatus", + "setWidget", + "setTitle", + "set_editor_text", + ]) { + assert.match(source, new RegExp(`case "${method}"`)); + } + assert.match(source, /setPendingExtension\(extension\)/); + assert.doesNotMatch(source, /This extension UI is unsupported/); + assert.match(source, /function stripAnsi/); + assert.match(source, /stripAnsi\(extension\.statusText\)/); + assert.match(source, /className="pi-controls"/); + assert.match(source, /aria-label="Model and thinking controls"/); + assert.doesNotMatch(source, /
/); + assert.doesNotMatch( + source, + /event\.ctrlKey && event\.key\.toLowerCase\(\) === "c"/, + ); + assert.match(source, /getCurrentWindow\(\)\s*\.hide\(\)\s*\.catch/); + assert.match( + source, + /window\.addEventListener\("keydown", handleKeydown, true\)/, + ); + assert.match(source, /useLayoutEffect/); + assert.match(source, /transcript\.scrollTop = transcript\.scrollHeight/); + assert.match(source, /requestAnimationFrame\(scrollToLatest\)/); + assert.match(source, /const loadGeneration = useRef\(0\)/); + assert.match( + source, + /const lastEventSequence = useRef>/, + ); + assert.match(source, /generation !== loadGeneration\.current/); + assert.match(source, /current\.phase !== "working"/); + assert.match(source, /bridgeEvent\.seq <= \(lastEventSequence\.current/); + assert.match(source, /\.toFixed\(1\)/); + assert.match(source, /formatTokens\(stats\.tokens\.input\)/); + assert.match(source, /formatTokens\(stats\.tokens\.output\)/); + assert.match(source, /formatTokens\(cacheTokens\)/); + assert.match(source, /function updateWorkProgress/); + assert.match( + source, + /className=\{`work-progress composer-progress \$\{workProgress\.phase\}`\}/, + ); + assert.match(source, /event\.type === "tool"/); + assert.match(source, /event\.type === "queue"/); + assert.match(source, /composer-progress/); + assert.doesNotMatch( + source, + /
{ + const [component, styles, capability] = await Promise.all([ + readFile("ui/src/App.tsx", "utf8"), + readFile("ui/src/App.css", "utf8"), + readFile("ui/src-tauri/capabilities/default.json", "utf8"), + ]); + assert.match(component, /startDragging\(\)/); + assert.match( + component, + /closest\(\s*"button, input, select, textarea",?\s*\)/, + ); + assert.match(styles, /\.workflow-header \{[\s\S]*cursor: grab/); + assert.match(capability, /core:window:allow-start-dragging/); +}); + +test("opens local follow-up choices for /model and /resume", async () => { + const [component, styles] = await Promise.all([ + readFile("ui/src/App.tsx", "utf8"), + readFile("ui/src/App.css", "utf8"), + ]); + assert.match(component, /const controlCommands: Command\[\]/); + assert.match(component, /followUp: "model"/); + assert.match(component, /followUp: "resume"/); + assert.match(component, /followUp: "thinking"/); + assert.match(component, /const nativeCommand = controlCommands\.find/); + assert.match(component, /function chooseThinking/); + assert.match(component, /function openCommandFollowUp/); + assert.match(component, /function chooseModel/); + assert.match(component, /className="command-followup"/); + assert.match(component, /New session/); + assert.match(styles, /\.command-followup \{/); + assert.match(styles, /\.command-choice-list \{/); +}); + +test("shows bridge-managed directory status and sessions for the opened directory", async () => { + const [component, styles] = await Promise.all([ + readFile("ui/src/App.tsx", "utf8"), + readFile("ui/src/App.css", "utf8"), + ]); + assert.match( + component, + /invoke<\{ directories: Directory\[\] \}>\("list_directories"\)/, + ); + assert.match( + component, + /invoke<\{ sessions: Session\[\] \}>\("list_sessions"/, + ); + assert.match(component, /function chooseDirectory/); + assert.match(component, /function startNewSession/); + assert.match(component, /function switchSession/); + assert.match(component, /className="session-panel"/); + assert.match(component, /New session/); + assert.match(component, /state\.isStreaming/); + assert.match(styles, /\.session-panel \{/); + assert.match(styles, /\.session-list \{/); +}); + +test("renders todo-plugin state and compact context/token status", async () => { + const [component, styles] = await Promise.all([ + readFile("ui/src/App.tsx", "utf8"), + readFile("ui/src/App.css", "utf8"), + ]); + assert.match(component, /function currentTodos/); + assert.match(component, /message\.toolName === "todo"/); + assert.match(component, /className="todos-pane"/); + assert.match(component, /className="workflow-main"/); + assert.match(component, /ctx \{formatTokens\(contextTokens\)\}/); + assert.match(component, /tok \{formatTokens\(stats\.tokens\.total\)\}/); + assert.match(styles, /\.workflow-main \{/); + assert.match(styles, /\.todos-pane \{/); + assert.match(styles, /\.status-metric \{/); +}); + +test("shows an optimistic prompt until the transcript receives it", async () => { + const [component, styles] = await Promise.all([ + readFile("ui/src/App.tsx", "utf8"), + readFile("ui/src/App.css", "utf8"), + ]); + assert.match(component, /type PendingSubmission/); + assert.match( + component, + /const \[pendingSubmissions, setPendingSubmissions\]/, + ); + assert.match(component, /setStatus\("Sending prompt to Pi…"\)/); + assert.match(component, /phase: "sending"/); + assert.match(component, /phase: "sent"/); + assert.match(component, /Sent · waiting for Pi/); + assert.match(component, /receivedMessages\.some/); + assert.match(component, /pending-message/); + assert.match(styles, /\.pending-message \{/); + assert.match(styles, /\.pending-message\.sending/); +}); + +test("distinguishes user and assistant messages", async () => { + const styles = await readFile("ui/src/App.css", "utf8"); + assert.match(styles, /\.message\.user \{/); + assert.match(styles, /background: #192536/); + assert.match(styles, /\.message\.assistant \{/); + assert.match(styles, /background: #1a241a/); +}); + +test("uses a subtle focus treatment for the prompt editor", async () => { + const styles = await readFile("ui/src/App.css", "utf8"); + assert.match(styles, /\.composer textarea:focus \{/); + assert.match(styles, /outline: none/); + assert.match(styles, /border-color: #6f8b56/); +}); + +test("submits the composer with Enter and preserves Shift+Enter for newlines", async () => { + const source = await readFile("ui/src/App.tsx", "utf8"); + assert.match(source, /onKeyDown=\{\(event\) => \{/); + assert.match(source, /event\.key !== "Enter"/); + assert.match(source, /event\.shiftKey/); + assert.match(source, /event\.nativeEvent\.isComposing/); + assert.match(source, /event\.preventDefault\(\);\s*void submit\(\);/); + assert.match(source, /Enter to send · Shift\+Enter for newline/); +}); + +test("uses a compact workflow layout that keeps the composer and transcript stable", async () => { + const [component, styles] = await Promise.all([ + readFile("ui/src/App.tsx", "utf8"), + readFile("ui/src/App.css", "utf8"), + ]); + assert.match(component, /className="workflow-header"/); + assert.match(component, /className="workflow"/); + assert.match(component, /className="workflow-footer"/); + assert.match(component, /className="composer-stack"/); + assert.match( + styles, + /\.workflow \{[\s\S]*grid-template-rows: minmax\(0, 1fr\) auto/, + ); + assert.match(styles, /\.transcript,\s*\.settings \{[\s\S]*overflow: auto/); + assert.match( + component, + /className=\{`transcript \$\{pendingExtension \? "with-extension" : ""\} \$\{workProgress\.phase\}`\}/, + ); + assert.match( + styles, + /\.transcript\.with-extension \{[\s\S]*padding-bottom: 112px/, + ); + assert.match(styles, /\.transcript\.working \{/); + assert.match(styles, /conic-gradient\(/); + assert.match(styles, /transcript-border-orbit/); + assert.match(styles, /prefers-reduced-motion: reduce/); + assert.match( + styles, + /\.workflow-main \{[\s\S]*grid-template-columns: minmax\(0, 1fr\) 210px/, + ); + assert.match(styles, /\.pi-controls \{[\s\S]*padding: 4px 6px/); +}); diff --git a/test/unix-server.test.js b/test/unix-server.test.js new file mode 100644 index 0000000..ae63365 --- /dev/null +++ b/test/unix-server.test.js @@ -0,0 +1,91 @@ +import assert from "node:assert/strict"; +import { mkdtemp, stat } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createConnection } from "node:net"; +import test from "node:test"; +import { startUnixSocketServer } from "../src/bridge/unix-server.js"; + +function connect(socketPath) { + return new Promise((resolve, reject) => { + const socket = createConnection(socketPath); + socket.once("connect", () => resolve(socket)); + socket.once("error", reject); + }); +} + +function readFrame(socket) { + return new Promise((resolve, reject) => { + let buffer = ""; + const onData = (chunk) => { + buffer += chunk; + const newline = buffer.indexOf("\n"); + if (newline === -1) return; + socket.off("data", onData); + try { + resolve(JSON.parse(buffer.slice(0, newline))); + } catch (error) { + reject(error); + } + }; + socket.on("data", onData); + socket.once("error", reject); + }); +} + +test("serves validated JSONL requests over an owner-only Unix socket", async () => { + const directory = await mkdtemp(join(tmpdir(), "pi-status-bridge-")); + const socketPath = join(directory, "bridge.sock"); + const bridge = await startUnixSocketServer({ + socketPath, + handleRequest: async (request) => ({ echoedOperation: request.op }), + }); + const socket = await connect(socketPath); + + try { + const socketStats = await stat(socketPath); + const mode = socketStats.mode & 0o777; + assert.equal(mode, 0o600); + + const response = readFrame(socket); + socket.write( + `${JSON.stringify({ version: "v1", id: "request-2", op: "list_agents" })}\r\n`, + ); + assert.deepEqual(await response, { + version: "v1", + id: "request-2", + ok: true, + result: { echoedOperation: "list_agents" }, + }); + } finally { + socket.destroy(); + await bridge.close(); + } +}); + +test("returns protocol errors without routing invalid input", async () => { + const directory = await mkdtemp(join(tmpdir(), "pi-status-bridge-")); + const socketPath = join(directory, "bridge.sock"); + const bridge = await startUnixSocketServer({ + socketPath, + handleRequest: async () => ({}), + }); + const socket = await connect(socketPath); + + try { + const response = readFrame(socket); + socket.write('{"version":"v1","id":"request-3","op":"unknown"}\n'); + assert.deepEqual(await response, { + version: "v1", + id: "request-3", + ok: false, + error: { + code: "unsupported_operation", + message: "operation unknown is not supported", + }, + }); + } finally { + socket.destroy(); + await bridge.close(); + } +}); diff --git a/ui/.gitignore b/ui/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/ui/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/ui/.vscode/extensions.json b/ui/.vscode/extensions.json new file mode 100644 index 0000000..24d7cc6 --- /dev/null +++ b/ui/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["tauri-apps.tauri-vscode", "rust-lang.rust-analyzer"] +} diff --git a/ui/README.md b/ui/README.md new file mode 100644 index 0000000..102e366 --- /dev/null +++ b/ui/README.md @@ -0,0 +1,7 @@ +# Tauri + React + Typescript + +This template should help get you started developing with Tauri, React and Typescript in Vite. + +## Recommended IDE Setup + +- [VS Code](https://code.visualstudio.com/) + [Tauri](https://marketplace.visualstudio.com/items?itemName=tauri-apps.tauri-vscode) + [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer) diff --git a/ui/index.html b/ui/index.html new file mode 100644 index 0000000..ff93803 --- /dev/null +++ b/ui/index.html @@ -0,0 +1,14 @@ + + + + + + + Tauri + React + Typescript + + + +
+ + + diff --git a/ui/package-lock.json b/ui/package-lock.json new file mode 100644 index 0000000..b73a2cc --- /dev/null +++ b/ui/package-lock.json @@ -0,0 +1,2116 @@ +{ + "name": "ui", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "ui", + "version": "0.1.0", + "dependencies": { + "@tauri-apps/api": "^2", + "@tauri-apps/plugin-opener": "^2", + "react": "^19.1.0", + "react-dom": "^19.1.0" + }, + "devDependencies": { + "@tauri-apps/cli": "^2", + "@types/react": "^19.1.8", + "@types/react-dom": "^19.1.6", + "@vitejs/plugin-react": "^4.6.0", + "typescript": "~5.8.3", + "vite": "^7.0.4" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tauri-apps/api": { + "version": "2.11.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.11.1.tgz", + "integrity": "sha512-M2FPuYND2m+wh5hfW9ZpSdxMPdEJovPBWwoHJmwUpysTYNHaOkVFN419m/K0LIgjb/7KU2vBgsUepJWugQCvAA==", + "license": "Apache-2.0 OR MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/tauri" + } + }, + "node_modules/@tauri-apps/cli": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.11.4.tgz", + "integrity": "sha512-R8xGtMpwyetawSqm9kYOuMmEqkhUbvcUy8n0aNXIxollKBLESUu5f4Fx+64hgASYm1H+jSWq6jCW6zqTnH6hqQ==", + "dev": true, + "license": "Apache-2.0 OR MIT", + "bin": { + "tauri": "tauri.js" + }, + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/tauri" + }, + "optionalDependencies": { + "@tauri-apps/cli-darwin-arm64": "2.11.4", + "@tauri-apps/cli-darwin-x64": "2.11.4", + "@tauri-apps/cli-linux-arm-gnueabihf": "2.11.4", + "@tauri-apps/cli-linux-arm64-gnu": "2.11.4", + "@tauri-apps/cli-linux-arm64-musl": "2.11.4", + "@tauri-apps/cli-linux-riscv64-gnu": "2.11.4", + "@tauri-apps/cli-linux-x64-gnu": "2.11.4", + "@tauri-apps/cli-linux-x64-musl": "2.11.4", + "@tauri-apps/cli-win32-arm64-msvc": "2.11.4", + "@tauri-apps/cli-win32-ia32-msvc": "2.11.4", + "@tauri-apps/cli-win32-x64-msvc": "2.11.4" + } + }, + "node_modules/@tauri-apps/cli-darwin-arm64": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.11.4.tgz", + "integrity": "sha512-1ryOF3ZhpZ/nemHV5zVwBQBz9jDGKmKPvWPADOhc83ig0P4bMc2iER4NbC6r9sjeIZ6RVQ4g3RZIYvezhcl4TQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-darwin-x64": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.11.4.tgz", + "integrity": "sha512-uFsGQAAfuyz1k/yGLmkWfkBlgKAqZfxqlHmLWx81QU27RJWfmbNHCIq8T8w1e+VClleIuZUjpHWfoE4E3DLo3A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-arm-gnueabihf": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.11.4.tgz", + "integrity": "sha512-IaHZn5CdBL21oUmjiVOS1ctw6Ip1O0pjp70FwOWmYz1myWe0SY96ZIj2FYf7pT0m8bI2h/hrs5ZbEXXh44/MkQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-arm64-gnu": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.11.4.tgz", + "integrity": "sha512-N41/ukTRVe6XSuUTESuFdGeOW2i7k62tK+6gHK5Kd5/q5RPvvi19GaWAVPPb9u95HSGmTChSolBfzynUsssFaA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-arm64-musl": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.11.4.tgz", + "integrity": "sha512-v277UnT/fB64xAfSroL5N3Km3tLmvATWqJJw/wRI+g6o+HkeD0slyE7gOhNs1MbjE41R7bQOTxMVoL3aomUJmw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-riscv64-gnu": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.11.4.tgz", + "integrity": "sha512-qqgNkQ2u1yZHxjhxsZaxUtRDW8dIqIYm33rx/mzwQv0SfY9x1B+iraj8vWeFiXjjSVVhEMepXSOts1TqPzvXNQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-x64-gnu": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.11.4.tgz", + "integrity": "sha512-2VRNWl84FOH0m2giiDkO2h0QXlcMJeX+zJDpI5kDIQAx6s+geF3v48F4DXfJez4GS/FdoDGnPnw1C2iYGbQ7bQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-x64-musl": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.11.4.tgz", + "integrity": "sha512-o9GyhYor/nc7xarmwDE3ka2szuW3uuZzXjHWh64Q8YX5AtSgxdQkFWzrY4O8KiGtVNvFBI14H3Q49Qj5TOIP/A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-win32-arm64-msvc": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.11.4.tgz", + "integrity": "sha512-ld5Ehb598m0VkYyylRPNeCFsBe/km0jxis6KgMpl3IGY6I/i1RwQXO05I1AsXUXO2WC6AvB/Lw4qTf/asiuEiQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-win32-ia32-msvc": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.11.4.tgz", + "integrity": "sha512-12Hxi0XX/H5VFxO/bGgHkFWhml9VMgEOu9CidjeCeTNQ1l6fpUlbiGgSP7CLI3PFtW9/FfbeHieZ+kyWK5H7CA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-win32-x64-msvc": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.11.4.tgz", + "integrity": "sha512-+vDiqBIU5dMISg/wNvX3sF+ZHfgJGJ5T0AcO+EHNXV9GGAG+P5fzodlDXD3QdKCRgZxMoCm5PPvj3BqLNjBthw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/plugin-opener": { + "version": "2.5.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.5.4.tgz", + "integrity": "sha512-1HnPkb+AmgO29HBazm4uPLKB+r7zzcTBW1d0fyYp1uP+jwtpoiNDGKMMzz58SFp49nOIrxdE3aUJtT57lfO9CQ==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.11.0" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.2.17", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", + "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.1", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.1.tgz", + "integrity": "sha512-HYXq73DDpCtNzOmrFsm9eSwCvWCql0RzqjpDzXN9EadiLJ4DNat0nsZ/Bzmy+Ud12mb4/zKDY0cQ805ZzN+i0A==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.7", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.7.tgz", + "integrity": "sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.44", + "caniuse-lite": "^1.0.30001806", + "electron-to-chromium": "^1.5.393", + "node-releases": "^2.0.51", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001806", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz", + "integrity": "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.396", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.396.tgz", + "integrity": "sha512-yHiw2Y3C3H9U6TMbOfoWK/BPreiOPXRfTWPBwQBoZG6/8TB6eOPnsy5oaRYuatR7Fw2SJ4kKforgufeo7fq0EQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.51", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz", + "integrity": "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.22", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.22.tgz", + "integrity": "sha512-KBDEIpLrvpv16pp3K0Fw+UCoZfopFjjgeB+0tA/aaThfEE74kKDLrgg603YvOWJyg3+WYtyq3xYsQWsIyZlPqQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.16", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", + "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.8" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "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==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.8.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", + "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "7.3.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", + "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0 || ^0.28.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/ui/package.json b/ui/package.json new file mode 100644 index 0000000..0d268fb --- /dev/null +++ b/ui/package.json @@ -0,0 +1,26 @@ +{ + "name": "pi-status-ui", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview", + "tauri": "tauri" + }, + "dependencies": { + "react": "^19.1.0", + "react-dom": "^19.1.0", + "@tauri-apps/api": "^2", + "@tauri-apps/plugin-opener": "^2" + }, + "devDependencies": { + "@types/react": "^19.1.8", + "@types/react-dom": "^19.1.6", + "@vitejs/plugin-react": "^4.6.0", + "typescript": "~5.8.3", + "vite": "^7.0.4", + "@tauri-apps/cli": "^2" + } +} diff --git a/ui/public/tauri.svg b/ui/public/tauri.svg new file mode 100644 index 0000000..31b62c9 --- /dev/null +++ b/ui/public/tauri.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/ui/public/vite.svg b/ui/public/vite.svg new file mode 100644 index 0000000..e7b8dfb --- /dev/null +++ b/ui/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ui/src-tauri/.gitignore b/ui/src-tauri/.gitignore new file mode 100644 index 0000000..b21bd68 --- /dev/null +++ b/ui/src-tauri/.gitignore @@ -0,0 +1,7 @@ +# Generated by Cargo +# will have compiled files and executables +/target/ + +# Generated by Tauri +# will have schema files for capabilities auto-completion +/gen/schemas diff --git a/ui/src-tauri/Cargo.lock b/ui/src-tauri/Cargo.lock new file mode 100644 index 0000000..9e86038 --- /dev/null +++ b/ui/src-tauri/Cargo.lock @@ -0,0 +1,4935 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195" +dependencies = [ + "alloc-no-stdlib", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "atk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "241b621213072e993be4f6f3a9e4b45f65b7e6faad43001be957184b7bb1824b" +dependencies = [ + "atk-sys", + "glib", + "libc", +] + +[[package]] +name = "atk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e48b684b0ca77d2bbadeef17424c2ea3c897d44d566a1617e7e8f30614d086" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +dependencies = [ + "serde_core", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + +[[package]] +name = "brotli" +version = "8.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +dependencies = [ + "serde", +] + +[[package]] +name = "cairo-rs" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" +dependencies = [ + "bitflags 2.13.1", + "cairo-sys-rs", + "glib", + "libc", + "once_cell", + "thiserror 1.0.69", +] + +[[package]] +name = "cairo-sys-rs" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "camino" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f2d30e4173c4026932d51d31d6b0613b1fd3014bf3f9f8943d4ba139c437ba0" +dependencies = [ + "serde_core", +] + +[[package]] +name = "cargo-platform" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo_metadata" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd5eb614ed4c27c5d706420e4320fbe3216ab31fa1c33cd8246ac36dae4479ba" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", + "thiserror 2.0.19", +] + +[[package]] +name = "cargo_toml" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "374b7c592d9c00c1f4972ea58390ac6b18cbb6ab79011f3bdc90a0b82ca06b77" +dependencies = [ + "serde", + "toml 0.9.12+spec-1.1.0", +] + +[[package]] +name = "cc" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89588d05638b5b4594a3348a2d6c20277e43a7f5c5202b05cc56888475a47b8" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cfb" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f" +dependencies = [ + "byteorder", + "fnv", + "uuid", +] + +[[package]] +name = "cfg-expr" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" +dependencies = [ + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "num-traits", + "serde", + "windows-link 0.2.1", +] + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "time", + "version_check", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" +dependencies = [ + "bitflags 2.13.1", + "core-foundation", + "core-graphics-types", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" +dependencies = [ + "bitflags 2.13.1", + "core-foundation", + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "cssparser" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dae61cf9c0abb83bd659dab65b7e4e38d8236824c85f0f804f173567bda257d2" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "phf", + "smallvec", +] + +[[package]] +name = "cssparser-macros" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "ctor" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "352d39c2f7bef1d6ad73db6f5160efcaed66d94ef8c6c573a8410c00bf909a98" +dependencies = [ + "ctor-proc-macro", + "dtor", +] + +[[package]] +name = "ctor-proc-macro" +version = "0.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.119", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "dbus" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ab69f03cc8c4340c9c8e315114e1658e6775a9b16a04357973aa21cec22b32e" +dependencies = [ + "libc", + "libdbus-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "serde_core", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.119", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.13.1", + "block2", + "libc", + "objc2", +] + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "dlopen2" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e2c5bd4158e66d1e215c49b837e11d62f3267b30c92f1d171c4d3105e3dc4d4" +dependencies = [ + "dlopen2_derive", + "libc", + "once_cell", + "winapi", +] + +[[package]] +name = "dlopen2_derive" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fbbb781877580993a8707ec48672673ec7b81eeba04cfd2310bd28c08e47c8f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "dom_query" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521e380c0c8afb8d9a1e83a1822ee03556fc3e3e7dbc1fd30be14e37f9cb3f89" +dependencies = [ + "bit-set", + "cssparser", + "foldhash", + "html5ever", + "precomputed-hash", + "selectors", + "tendril", +] + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" +dependencies = [ + "serde", +] + +[[package]] +name = "dtoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" + +[[package]] +name = "dtoa-short" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" +dependencies = [ + "dtoa", +] + +[[package]] +name = "dtor" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1057d6c64987086ff8ed0fd3fbf377a6b7d205cc7715868cd401705f715cbe4" +dependencies = [ + "dtor-proc-macro", +] + +[[package]] +name = "dtor-proc-macro" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f678cf4a922c215c63e0de95eb1ff08a958a81d47e485cf9da1e27bf6305cfa5" + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "embed-resource" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbfdaacccebec3b28e4866b8973543c7647797db5ada1bdab552e48fe665fbbd" +dependencies = [ + "cc", + "memchr", + "rustc_version", + "toml 1.1.3+spec-1.1.0", + "vswhom", + "winreg", +] + +[[package]] +name = "embed_plist" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" + +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "erased-serde" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec" +dependencies = [ + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "field-offset" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" +dependencies = [ + "memoffset", + "rustc_version", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" + +[[package]] +name = "futures-executor" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "futures-sink" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" + +[[package]] +name = "futures-task" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" + +[[package]] +name = "futures-util" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +dependencies = [ + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "gdk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9f245958c627ac99d8e529166f9823fb3b838d1d41fd2b297af3075093c2691" +dependencies = [ + "cairo-rs", + "gdk-pixbuf", + "gdk-sys", + "gio", + "glib", + "libc", + "pango", +] + +[[package]] +name = "gdk-pixbuf" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50e1f5f1b0bfb830d6ccc8066d18db35c487b1b2b1e8589b5dfe9f07e8defaec" +dependencies = [ + "gdk-pixbuf-sys", + "gio", + "glib", + "libc", + "once_cell", +] + +[[package]] +name = "gdk-pixbuf-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9839ea644ed9c97a34d129ad56d38a25e6756f99f3a88e15cd39c20629caf7" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gdk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c2d13f38594ac1e66619e188c6d5a1adb98d11b2fcf7894fc416ad76aa2f3f7" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkwayland-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "140071d506d223f7572b9f09b5e155afbd77428cd5cc7af8f2694c41d98dfe69" +dependencies = [ + "gdk-sys", + "glib-sys", + "gobject-sys", + "libc", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkx11" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3caa00e14351bebbc8183b3c36690327eb77c49abc2268dd4bd36b856db3fbfe" +dependencies = [ + "gdk", + "gdkx11-sys", + "gio", + "glib", + "libc", + "x11", +] + +[[package]] +name = "gdkx11-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e7445fe01ac26f11601db260dd8608fe172514eb63b3b5e261ea6b0f4428d" +dependencies = [ + "gdk-sys", + "glib-sys", + "libc", + "system-deps", + "x11", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] + +[[package]] +name = "gio" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4fc8f532f87b79cbc51a79748f16a6828fb784be93145a322fa14d06d354c73" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "gio-sys", + "glib", + "libc", + "once_cell", + "pin-project-lite", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "gio-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37566df850baf5e4cb0dfb78af2e4b9898d817ed9263d1090a2df958c64737d2" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", + "winapi", +] + +[[package]] +name = "glib" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" +dependencies = [ + "bitflags 2.13.1", + "futures-channel", + "futures-core", + "futures-executor", + "futures-task", + "futures-util", + "gio-sys", + "glib-macros", + "glib-sys", + "gobject-sys", + "libc", + "memchr", + "once_cell", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "glib-macros" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb0228f477c0900c880fd78c8759b95c7636dbd7842707f49e132378aa2acdc" +dependencies = [ + "heck 0.4.1", + "proc-macro-crate 2.0.2", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "glib-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063ce2eb6a8d0ea93d2bf8ba1957e78dbab6be1c2220dd3daca57d5a9d869898" +dependencies = [ + "libc", + "system-deps", +] + +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + +[[package]] +name = "gobject-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0850127b514d1c4a4654ead6dedadb18198999985908e6ffe4436f53c785ce44" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gtk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd56fb197bfc42bd5d2751f4f017d44ff59fbb58140c6b49f9b3b2bdab08506a" +dependencies = [ + "atk", + "cairo-rs", + "field-offset", + "futures-channel", + "gdk", + "gdk-pixbuf", + "gio", + "glib", + "gtk-sys", + "gtk3-macros", + "libc", + "pango", + "pkg-config", +] + +[[package]] +name = "gtk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f29a1c21c59553eb7dd40e918be54dccd60c52b049b75119d5d96ce6b624414" +dependencies = [ + "atk-sys", + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "gtk3-macros" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ff3c5b21f14f0736fed6dcfc0bfb4225ebf5725f3c0209edeec181e4d73e9d" +dependencies = [ + "proc-macro-crate 1.3.1", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "html5ever" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1054432bae2f14e0061e33d23402fbaa67a921d319d56adc6bcf887ddad1cbc2" +dependencies = [ + "log", + "markup5ever", +] + +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core 0.62.2", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "ico" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371" +dependencies = [ + "byteorder", + "png 0.17.16", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "infer" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a588916bfdfd92e71cacef98a63d9b1f0d74d6599980d11894290e7ddefffcf7" +dependencies = [ + "cfb", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "javascriptcore-rs" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca5671e9ffce8ffba57afc24070e906da7fc4b1ba66f2cabebf61bf2ea257fcc" +dependencies = [ + "bitflags 1.3.2", + "glib", + "javascriptcore-rs-sys", +] + +[[package]] +name = "javascriptcore-rs-sys" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af1be78d14ffa4b75b66df31840478fef72b51f8c2465d4ca7c194da9f7a5124" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "json-patch" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863726d7afb6bc2590eeff7135d923545e5e964f004c2ccf8716c25e70a86f08" +dependencies = [ + "jsonptr", + "serde", + "serde_json", + "thiserror 1.0.69", +] + +[[package]] +name = "jsonptr" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dea2b27dd239b2556ed7a25ba842fe47fd602e7fc7433c2a8d6106d4d9edd70" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "keyboard-types" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" +dependencies = [ + "bitflags 2.13.1", + "serde", + "unicode-segmentation", +] + +[[package]] +name = "libappindicator" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03589b9607c868cc7ae54c0b2a22c8dc03dd41692d48f2d7df73615c6a95dc0a" +dependencies = [ + "glib", + "gtk", + "gtk-sys", + "libappindicator-sys", + "log", +] + +[[package]] +name = "libappindicator-sys" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf" +dependencies = [ + "gtk-sys", + "libloading", + "once_cell", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libdbus-sys" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043" +dependencies = [ + "pkg-config", +] + +[[package]] +name = "libloading" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" +dependencies = [ + "cfg-if", + "winapi", +] + +[[package]] +name = "libredox" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" +dependencies = [ + "libc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "markup5ever" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8983d30f2915feeaaab2d6babdd6bc7e9ed1a00b66b5e6d74df19aa9c0e91862" +dependencies = [ + "log", + "tendril", + "web_atoms", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "muda" +version = "0.19.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dd04e60bc0b07438a6771710ee1698f98f6ebbc7f89b61264af1563b8aeb878" +dependencies = [ + "crossbeam-channel", + "dpi", + "gtk", + "keyboard-types", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "once_cell", + "png 0.18.1", + "serde", + "thiserror 2.0.19", + "windows-sys 0.61.2", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.13.1", + "jni-sys 0.3.1", + "log", + "ndk-sys", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", + "objc2-exception-helper", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.13.1", + "block2", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" +dependencies = [ + "bitflags 2.13.1", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-data" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.13.1", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.13.1", + "dispatch2", + "objc2", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-core-image" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-location" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-text" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" +dependencies = [ + "bitflags 2.13.1", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-exception-helper" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7a1c5fbb72d7735b076bb47b578523aedc40f3c439bea6dfd595c089d79d98a" +dependencies = [ + "cc", +] + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.13.1", + "block2", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.13.1", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags 2.13.1", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" +dependencies = [ + "bitflags 2.13.1", + "block2", + "objc2", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image", + "objc2-core-location", + "objc2-core-text", + "objc2-foundation", + "objc2-quartz-core", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-web-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f" +dependencies = [ + "bitflags 2.13.1", + "block2", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "pango" +version = "0.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ca27ec1eb0457ab26f3036ea52229edbdb74dee1edd29063f5b9b010e7ebee4" +dependencies = [ + "gio", + "glib", + "libc", + "once_cell", + "pango-sys", +] + +[[package]] +name = "pango-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "436737e391a843e5933d6d9aa102cb126d501e815b83601365a948a518555dc5" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link 0.2.1", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros", + "phf_shared", + "serde", +] + +[[package]] +name = "phf_codegen" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared", +] + +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pi-status-ui" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "tauri", + "tauri-build", + "tauri-plugin-single-instance", + "tokio", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "plist" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da1d65da6dd5d1e44199ac0f58712d241c0f439f80adea8924d832384087f85" +dependencies = [ + "base64 0.22.1", + "indexmap 2.14.0", + "quick-xml", + "serde", + "time", +] + +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.13.1", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[package]] +name = "proc-macro-crate" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" +dependencies = [ + "once_cell", + "toml_edit 0.19.15", +] + +[[package]] +name = "proc-macro-crate" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b00f26d3400549137f92511a46ac1cd8ce37cb5598a96d382381458b992a5d24" +dependencies = [ + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.13+spec-1.1.0", +] + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quick-xml" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +dependencies = [ + "memchr", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.19", +] + +[[package]] +name = "ref-cast" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "216e8f773d7923bcba9ceb86a86c93cabb3903a11872fc3f138c49630e50b96d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9283685feec7d69af75fb0e858d5e7378f33fe4fc699383b2916ab9273e03c" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.13.1", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schemars" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" +dependencies = [ + "dyn-clone", + "indexmap 1.9.3", + "schemars_derive", + "serde", + "serde_json", + "url", + "uuid", +] + +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.119", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "selectors" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c" +dependencies = [ + "bitflags 2.13.1", + "cssparser", + "derive_more", + "log", + "new_debug_unreachable", + "phf", + "phf_codegen", + "precomputed-hash", + "rustc-hash", + "servo_arc", + "smallvec", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-untagged" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9faf48a4a2d2693be24c6289dbe26552776eb7737074e6722891fadbe6c5058" +dependencies = [ + "erased-serde", + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_repr" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_with" +version = "3.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c" +dependencies = [ + "base64 0.22.1", + "bs58", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.14.0", + "schemars 0.9.0", + "schemars 1.2.1", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "serialize-to-javascript" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04f3666a07a197cdb77cdf306c32be9b7f598d7060d50cfd4d5aa04bfd92f6c5" +dependencies = [ + "serde", + "serde_json", + "serialize-to-javascript-impl", +] + +[[package]] +name = "serialize-to-javascript-impl" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "772ee033c0916d670af7860b6e1ef7d658a4629a6d0b4c8c3e67f09b3765b75d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "servo_arc" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930" +dependencies = [ + "stable_deref_trait", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "softbuffer" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aac18da81ebbf05109ab275b157c22a653bb3c12cf884450179942f81bcbf6c3" +dependencies = [ + "bytemuck", + "js-sys", + "ndk", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "objc2-quartz-core", + "raw-window-handle", + "redox_syscall", + "tracing", + "wasm-bindgen", + "web-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "soup3" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "471f924a40f31251afc77450e781cb26d55c0b650842efafc9c6cbd2f7cc4f9f" +dependencies = [ + "futures-channel", + "gio", + "glib", + "libc", + "soup3-sys", +] + +[[package]] +name = "soup3-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ebe8950a680a12f24f15ebe1bf70db7af98ad242d9db43596ad3108aab86c27" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "string_cache" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared", + "precomputed-hash", +] + +[[package]] +name = "string_cache_codegen" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "swift-rs" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4057c98e2e852d51fdcfca832aac7b571f6b351ad159f9eda5db1655f8d0c4d7" +dependencies = [ + "base64 0.21.7", + "serde", + "serde_json", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "system-deps" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" +dependencies = [ + "cfg-expr", + "heck 0.5.0", + "pkg-config", + "toml 0.8.2", + "version-compare", +] + +[[package]] +name = "tao" +version = "0.35.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9" +dependencies = [ + "bitflags 2.13.1", + "block2", + "core-foundation", + "core-graphics", + "crossbeam-channel", + "dbus", + "dispatch2", + "dlopen2", + "dpi", + "gdkwayland-sys", + "gdkx11-sys", + "gtk", + "jni", + "libc", + "log", + "ndk", + "ndk-sys", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "objc2-ui-kit", + "once_cell", + "parking_lot", + "percent-encoding", + "raw-window-handle", + "tao-macros", + "unicode-segmentation", + "url", + "windows", + "windows-core 0.61.2", + "windows-version", + "x11-dl", +] + +[[package]] +name = "tao-macros" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4e16beb8b2ac17db28eab8bca40e62dbfbb34c0fcdc6d9826b11b7b5d047dfd" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "target-lexicon" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" + +[[package]] +name = "tauri" +version = "2.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "667b20e2726d572dea2de7370da16e188eb06008faf9a92fab7cdc46791190b5" +dependencies = [ + "anyhow", + "bytes", + "cookie", + "dirs", + "dunce", + "embed_plist", + "getrandom 0.3.4", + "glob", + "gtk", + "heck 0.5.0", + "http", + "jni", + "libc", + "log", + "mime", + "muda", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "objc2-ui-kit", + "objc2-web-kit", + "percent-encoding", + "plist", + "raw-window-handle", + "reqwest", + "serde", + "serde_json", + "serde_repr", + "serialize-to-javascript", + "swift-rs", + "tauri-build", + "tauri-macros", + "tauri-runtime", + "tauri-runtime-wry", + "tauri-utils", + "thiserror 2.0.19", + "tokio", + "tray-icon", + "url", + "webkit2gtk", + "webview2-com", + "window-vibrancy", + "windows", +] + +[[package]] +name = "tauri-build" +version = "2.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc9ce40b16101cb6ea63d3e221567affd1c3a9205f95d7bc574941a10636b632" +dependencies = [ + "anyhow", + "cargo_toml", + "dirs", + "glob", + "heck 0.5.0", + "json-patch", + "schemars 0.8.22", + "semver", + "serde", + "serde_json", + "tauri-utils", + "tauri-winres", + "walkdir", +] + +[[package]] +name = "tauri-codegen" +version = "2.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08279169ff42f8fc45a1dbc9dcae888893ba95288142e5880c59b93a26d2cfc5" +dependencies = [ + "base64 0.22.1", + "brotli", + "ico", + "json-patch", + "plist", + "png 0.17.16", + "proc-macro2", + "quote", + "semver", + "serde", + "serde_json", + "sha2", + "syn 2.0.119", + "tauri-utils", + "thiserror 2.0.19", + "time", + "url", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-macros" +version = "2.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8b394794f399a421811d06966343e7933fcae92d59f5180b9388d1174497a45" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", + "tauri-codegen", + "tauri-utils", +] + +[[package]] +name = "tauri-plugin-single-instance" +version = "2.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3214becf9ef5783c0ae99a3bb25adf5353a7a16ebf53e74b909e29205735c6c" +dependencies = [ + "serde", + "serde_json", + "tauri", + "thiserror 2.0.19", + "tokio", + "tracing", + "windows-sys 0.60.2", + "zbus", +] + +[[package]] +name = "tauri-runtime" +version = "2.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0b4bc95aed361b0019067d189a1174a603d460d0f6c72606512d59fc9c12ec8" +dependencies = [ + "cookie", + "dpi", + "gtk", + "http", + "jni", + "objc2", + "objc2-ui-kit", + "objc2-web-kit", + "raw-window-handle", + "serde", + "serde_json", + "tauri-utils", + "thiserror 2.0.19", + "url", + "webkit2gtk", + "webview2-com", + "windows", +] + +[[package]] +name = "tauri-runtime-wry" +version = "2.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e6fac707727b7a2f48e4ded90976324267371073edbb415ffb73bb0458d203f" +dependencies = [ + "gtk", + "http", + "jni", + "log", + "objc2", + "objc2-app-kit", + "once_cell", + "percent-encoding", + "raw-window-handle", + "softbuffer", + "tao", + "tauri-runtime", + "tauri-utils", + "url", + "webkit2gtk", + "webview2-com", + "windows", + "wry", +] + +[[package]] +name = "tauri-utils" +version = "2.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e176a18e67764923c4f1ce66f25ae4abe5f688384d5eb1a0fa6c77f3d90f887" +dependencies = [ + "anyhow", + "brotli", + "cargo_metadata", + "ctor", + "dom_query", + "dunce", + "glob", + "http", + "infer", + "json-patch", + "log", + "memchr", + "phf", + "plist", + "proc-macro2", + "quote", + "regex", + "schemars 0.8.22", + "semver", + "serde", + "serde-untagged", + "serde_json", + "serde_with", + "swift-rs", + "thiserror 2.0.19", + "toml 1.1.3+spec-1.1.0", + "url", + "urlpattern", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-winres" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc65d45c68858bfe420dd29e834b5d15dbecf8a07a8a16cf4d532c7b1f69d4b6" +dependencies = [ + "dunce", + "embed-resource", + "toml 1.1.3+spec-1.1.0", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "tendril" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fed54709c5b3a53d09bb1c113ea4f5ceafd1e772ddcb0030a82e1d56c087b08" +dependencies = [ + "new_debug_unreachable", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" +dependencies = [ + "thiserror-impl 2.0.19", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "time" +version = "0.3.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e1d5e639ff6bab73cb6885cc7e7b1de96c3f32c68ec55f3952614bec1092244" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d" +dependencies = [ + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap 2.14.0", + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 0.7.15", +] + +[[package]] +name = "toml" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c96ecdfa941c8fc4fcaed14f99ada8ebed502eef533015095a07e3301d4c3c" +dependencies = [ + "indexmap 2.14.0", + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 1.0.4", +] + +[[package]] +name = "toml_datetime" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cda73e2f1397b1262d6dfdcef8aafae14d1de7748d66822d3bfeeb6d03e5e4b" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.19.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" +dependencies = [ + "indexmap 2.14.0", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338" +dependencies = [ + "indexmap 2.14.0", + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.25.13+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" +dependencies = [ + "indexmap 2.14.0", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "winnow 1.0.4", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow 1.0.4", +] + +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags 2.13.1", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "tray-icon" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65ba1e5f6b9ef9fd87e21b9c6f351554dbd717960089168fcfdef854686961dc" +dependencies = [ + "crossbeam-channel", + "dirs", + "libappindicator", + "muda", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "once_cell", + "png 0.18.1", + "serde", + "thiserror 2.0.19", + "windows-sys 0.61.2", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + +[[package]] +name = "unic-char-property" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8c57a407d9b6fa02b4795eb81c5b6652060a15a7903ea981f3d723e6c0be221" +dependencies = [ + "unic-char-range", +] + +[[package]] +name = "unic-char-range" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0398022d5f700414f6b899e10b8348231abf9173fa93144cbc1a43b9793c1fbc" + +[[package]] +name = "unic-common" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d7ff825a6a654ee85a63e80f92f054f904f21e7d12da4e22f9834a4aaa35bc" + +[[package]] +name = "unic-ucd-ident" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e230a37c0381caa9219d67cf063aa3a375ffed5bf541a452db16e744bdab6987" +dependencies = [ + "unic-char-property", + "unic-char-range", + "unic-ucd-version", +] + +[[package]] +name = "unic-ucd-version" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96bd2f2237fe450fcd0a1d2f5f4e91711124f7857ba2e964247776ebeeb7b0c4" +dependencies = [ + "unic-common", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "urlpattern" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70acd30e3aa1450bc2eece896ce2ad0d178e9c079493819301573dae3c37ba6d" +dependencies = [ + "regex", + "serde", + "unic-ucd-ident", + "url", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "version-compare" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vswhom" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be979b7f07507105799e854203b470ff7c78a1639e330a58f183b5fea574608b" +dependencies = [ + "libc", + "vswhom-sys", +] + +[[package]] +name = "vswhom-sys" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb067e4cbd1ff067d1df46c9194b5de0e98efd2810bbc95c5d5e5f25a3231150" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "web-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web_atoms" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "075474b12bcb3d2e3d4546580e9de478eeeead668a1761e2a8860c836b7ef297" +dependencies = [ + "phf", + "phf_codegen", + "string_cache", + "string_cache_codegen", +] + +[[package]] +name = "webkit2gtk" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1027150013530fb2eaf806408df88461ae4815a45c541c8975e61d6f2fc4793" +dependencies = [ + "bitflags 1.3.2", + "cairo-rs", + "gdk", + "gdk-sys", + "gio", + "gio-sys", + "glib", + "glib-sys", + "gobject-sys", + "gtk", + "gtk-sys", + "javascriptcore-rs", + "libc", + "once_cell", + "soup3", + "webkit2gtk-sys", +] + +[[package]] +name = "webkit2gtk-sys" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916a5f65c2ef0dfe12fff695960a2ec3d4565359fdbb2e9943c974e06c734ea5" +dependencies = [ + "bitflags 1.3.2", + "cairo-sys-rs", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "gtk-sys", + "javascriptcore-rs-sys", + "libc", + "pkg-config", + "soup3-sys", + "system-deps", +] + +[[package]] +name = "webview2-com" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a" +dependencies = [ + "webview2-com-macros", + "webview2-com-sys", + "windows", + "windows-core 0.61.2", + "windows-implement", + "windows-interface", +] + +[[package]] +name = "webview2-com-macros" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67a921c1b6914c367b2b823cd4cde6f96beec77d30a939c8199bb377cf9b9b54" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "webview2-com-sys" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c" +dependencies = [ + "thiserror 2.0.19", + "windows", + "windows-core 0.61.2", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "window-vibrancy" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9bec5a31f3f9362f2258fd0e9c9dd61a9ca432e7306cc78c444258f0dce9a9c" +dependencies = [ + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "raw-window-handle", + "windows-sys 0.59.0", + "windows-version", +] + +[[package]] +name = "windows" +version = "0.61.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +dependencies = [ + "windows-collections", + "windows-core 0.61.2", + "windows-future", + "windows-link 0.1.3", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core 0.61.2", +] + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-future" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link 0.2.1", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-version" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "0.5.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "winreg" +version = "0.55.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97" +dependencies = [ + "cfg-if", + "windows-sys 0.59.0", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "wry" +version = "0.55.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "186f9871daa55fd9c016578b810d149de58367113db7fb72b462d2323ce19514" +dependencies = [ + "base64 0.22.1", + "block2", + "cookie", + "crossbeam-channel", + "dirs", + "dom_query", + "dpi", + "dunce", + "gdkx11", + "gtk", + "http", + "javascriptcore-rs", + "jni", + "libc", + "ndk", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "objc2-ui-kit", + "objc2-web-kit", + "once_cell", + "percent-encoding", + "raw-window-handle", + "sha2", + "soup3", + "tao-macros", + "thiserror 2.0.19", + "url", + "webkit2gtk", + "webkit2gtk-sys", + "webview2-com", + "windows", + "windows-core 0.61.2", + "windows-version", + "x11-dl", +] + +[[package]] +name = "x11" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zbus" +version = "5.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe18fb60dc696039e738717b76eaea21e7a4489bbb1885020b43c94236d7e98a" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow 1.0.4", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe96480bed92df2b442a1a30df364e12d08eed03aeb061f2b8dc6afb2be91119" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" +dependencies = [ + "serde", + "winnow 1.0.4", + "zvariant", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zvariant" +version = "5.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee2a0bcd2a907786a456fff45aaaaf54c9ba5f50b71ae9ec1a4edd200c94911" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow 1.0.4", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38a708216a18780796770bfe3f4739c7c83a3e8f789b755534bbbc06e4e23e12" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90cb9383f9b45290407a1258b202d3f8f01db719eb60b4e4055c6375af4fc7c7" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 2.0.119", + "winnow 1.0.4", +] diff --git a/ui/src-tauri/Cargo.toml b/ui/src-tauri/Cargo.toml new file mode 100644 index 0000000..a2bc07a --- /dev/null +++ b/ui/src-tauri/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "pi-status-ui" +version = "0.1.0" +description = "Standalone desktop client for Pi Status Bridge" +authors = ["alex"] +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[lib] +# The `_lib` suffix may seem redundant but it is necessary +# to make the lib name unique and wouldn't conflict with the bin name. +# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519 +name = "pi_status_ui_lib" +crate-type = ["staticlib", "cdylib", "rlib"] + +[build-dependencies] +tauri-build = { version = "2", features = [] } + +[dependencies] +tauri = { version = "2", features = [] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tokio = { version = "1", features = ["io-util", "net", "rt", "macros"] } + +[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] +tauri-plugin-single-instance = "2" diff --git a/ui/src-tauri/build.rs b/ui/src-tauri/build.rs new file mode 100644 index 0000000..d860e1e --- /dev/null +++ b/ui/src-tauri/build.rs @@ -0,0 +1,3 @@ +fn main() { + tauri_build::build() +} diff --git a/ui/src-tauri/capabilities/default.json b/ui/src-tauri/capabilities/default.json new file mode 100644 index 0000000..45461e2 --- /dev/null +++ b/ui/src-tauri/capabilities/default.json @@ -0,0 +1,11 @@ +{ + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "default", + "description": "Capability for the main window", + "windows": ["main"], + "permissions": [ + "core:default", + "core:window:allow-hide", + "core:window:allow-start-dragging" + ] +} diff --git a/ui/src-tauri/icons/128x128.png b/ui/src-tauri/icons/128x128.png new file mode 100644 index 0000000..6be5e50 Binary files /dev/null and b/ui/src-tauri/icons/128x128.png differ diff --git a/ui/src-tauri/icons/128x128@2x.png b/ui/src-tauri/icons/128x128@2x.png new file mode 100644 index 0000000..e81bece Binary files /dev/null and b/ui/src-tauri/icons/128x128@2x.png differ diff --git a/ui/src-tauri/icons/32x32.png b/ui/src-tauri/icons/32x32.png new file mode 100644 index 0000000..a437dd5 Binary files /dev/null and b/ui/src-tauri/icons/32x32.png differ diff --git a/ui/src-tauri/icons/Square107x107Logo.png b/ui/src-tauri/icons/Square107x107Logo.png new file mode 100644 index 0000000..0ca4f27 Binary files /dev/null and b/ui/src-tauri/icons/Square107x107Logo.png differ diff --git a/ui/src-tauri/icons/Square142x142Logo.png b/ui/src-tauri/icons/Square142x142Logo.png new file mode 100644 index 0000000..b81f820 Binary files /dev/null and b/ui/src-tauri/icons/Square142x142Logo.png differ diff --git a/ui/src-tauri/icons/Square150x150Logo.png b/ui/src-tauri/icons/Square150x150Logo.png new file mode 100644 index 0000000..624c7bf Binary files /dev/null and b/ui/src-tauri/icons/Square150x150Logo.png differ diff --git a/ui/src-tauri/icons/Square284x284Logo.png b/ui/src-tauri/icons/Square284x284Logo.png new file mode 100644 index 0000000..c021d2b Binary files /dev/null and b/ui/src-tauri/icons/Square284x284Logo.png differ diff --git a/ui/src-tauri/icons/Square30x30Logo.png b/ui/src-tauri/icons/Square30x30Logo.png new file mode 100644 index 0000000..6219700 Binary files /dev/null and b/ui/src-tauri/icons/Square30x30Logo.png differ diff --git a/ui/src-tauri/icons/Square310x310Logo.png b/ui/src-tauri/icons/Square310x310Logo.png new file mode 100644 index 0000000..f9bc048 Binary files /dev/null and b/ui/src-tauri/icons/Square310x310Logo.png differ diff --git a/ui/src-tauri/icons/Square44x44Logo.png b/ui/src-tauri/icons/Square44x44Logo.png new file mode 100644 index 0000000..d5fbfb2 Binary files /dev/null and b/ui/src-tauri/icons/Square44x44Logo.png differ diff --git a/ui/src-tauri/icons/Square71x71Logo.png b/ui/src-tauri/icons/Square71x71Logo.png new file mode 100644 index 0000000..63440d7 Binary files /dev/null and b/ui/src-tauri/icons/Square71x71Logo.png differ diff --git a/ui/src-tauri/icons/Square89x89Logo.png b/ui/src-tauri/icons/Square89x89Logo.png new file mode 100644 index 0000000..f3f705a Binary files /dev/null and b/ui/src-tauri/icons/Square89x89Logo.png differ diff --git a/ui/src-tauri/icons/StoreLogo.png b/ui/src-tauri/icons/StoreLogo.png new file mode 100644 index 0000000..4556388 Binary files /dev/null and b/ui/src-tauri/icons/StoreLogo.png differ diff --git a/ui/src-tauri/icons/icon.icns b/ui/src-tauri/icons/icon.icns new file mode 100644 index 0000000..12a5bce Binary files /dev/null and b/ui/src-tauri/icons/icon.icns differ diff --git a/ui/src-tauri/icons/icon.ico b/ui/src-tauri/icons/icon.ico new file mode 100644 index 0000000..b3636e4 Binary files /dev/null and b/ui/src-tauri/icons/icon.ico differ diff --git a/ui/src-tauri/icons/icon.png b/ui/src-tauri/icons/icon.png new file mode 100644 index 0000000..e1cd261 Binary files /dev/null and b/ui/src-tauri/icons/icon.png differ diff --git a/ui/src-tauri/src/bridge.rs b/ui/src-tauri/src/bridge.rs new file mode 100644 index 0000000..de1e632 --- /dev/null +++ b/ui/src-tauri/src/bridge.rs @@ -0,0 +1,350 @@ +use serde::Serialize; +use serde_json::{json, Value}; +use std::env; +use tauri::{AppHandle, Emitter}; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::net::UnixStream; + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct BridgeRequest<'a> { + version: &'static str, + id: &'a str, + op: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + agent_id: Option<&'a str>, + #[serde(skip_serializing_if = "Option::is_none")] + payload: Option, +} + +pub fn default_socket_path() -> Result { + env::var("PI_STATUS_BRIDGE_SOCKET") + .or_else(|_| { + env::var("XDG_RUNTIME_DIR") + .map(|runtime| format!("{runtime}/pi-status-bridge/bridge.sock")) + }) + .map_err(|_| "Pi Status Bridge socket is unavailable".to_owned()) +} + +async fn connect_and_send( + socket_path: &str, + operation: &str, + agent_id: Option<&str>, + payload: Option, +) -> Result, String> { + let mut stream = UnixStream::connect(socket_path) + .await + .map_err(|error| format!("Could not connect to Pi Status Bridge: {error}"))?; + let request = BridgeRequest { + version: "v1", + id: "tauri-ui", + op: operation, + agent_id, + payload, + }; + let encoded = serde_json::to_string(&request).map_err(|error| error.to_string())?; + stream + .write_all(format!("{encoded}\n").as_bytes()) + .await + .map_err(|error| format!("Could not send bridge request: {error}"))?; + Ok(BufReader::new(stream)) +} + +fn result_from_response(value: Value) -> Result { + if value.get("ok") != Some(&Value::Bool(true)) { + return Err(value + .get("error") + .and_then(|error| error.get("message")) + .and_then(Value::as_str) + .unwrap_or("Pi Status Bridge rejected the request") + .to_owned()); + } + value + .get("result") + .cloned() + .ok_or_else(|| "Pi Status Bridge returned no result".to_owned()) +} + +pub async fn request( + socket_path: &str, + operation: &str, + agent_id: Option<&str>, + payload: Option, +) -> Result { + let mut reader = connect_and_send(socket_path, operation, agent_id, payload).await?; + let mut response = String::new(); + reader + .read_line(&mut response) + .await + .map_err(|error| format!("Could not read bridge response: {error}"))?; + let value: Value = serde_json::from_str(&response) + .map_err(|error| format!("Bridge returned invalid JSON: {error}"))?; + result_from_response(value) +} + +pub async fn list_agents(socket_path: &str) -> Result { + request(socket_path, "list_agents", None, None).await +} + +pub async fn list_directories(socket_path: &str) -> Result { + request(socket_path, "list_directories", None, None).await +} + +pub async fn select_worktree(socket_path: &str, worktree_path: &str) -> Result { + request( + socket_path, + "select_agent", + None, + Some(json!({ "worktreePath": worktree_path })), + ) + .await +} + +pub async fn list_sessions(socket_path: &str, agent_id: &str) -> Result { + request(socket_path, "list_sessions", Some(agent_id), None).await +} + +pub async fn switch_session( + socket_path: &str, + agent_id: &str, + session_path: &str, +) -> Result { + request( + socket_path, + "switch_session", + Some(agent_id), + Some(json!({ "sessionPath": session_path })), + ) + .await +} + +pub async fn new_session(socket_path: &str, agent_id: &str) -> Result { + request(socket_path, "new_session", Some(agent_id), None).await +} + +pub async fn load_agent(socket_path: &str, agent_id: &str) -> Result { + // Older bridge daemons can still serve the conversation while they await a restart. + let stats = request(socket_path, "get_session_stats", Some(agent_id), None) + .await + .unwrap_or_else(|_| json!({ "data": {} })); + let (state, transcript, commands, models) = tokio::try_join!( + request(socket_path, "get_state", Some(agent_id), None), + request(socket_path, "get_transcript", Some(agent_id), None), + request(socket_path, "get_commands", Some(agent_id), None), + request(socket_path, "get_available_models", Some(agent_id), None), + )?; + Ok(json!({ + "state": state, + "stats": stats, + "transcript": transcript, + "commands": commands, + "models": models + })) +} + +pub async fn submit_prompt(socket_path: &str, agent_id: &str, message: &str) -> Result<(), String> { + request( + socket_path, + "submit_prompt", + Some(agent_id), + Some(json!({ "message": message })), + ) + .await + .map(|_| ()) +} + +pub async fn abort(socket_path: &str, agent_id: &str) -> Result<(), String> { + request(socket_path, "abort", Some(agent_id), None).await.map(|_| ()) +} + +pub async fn set_model( + socket_path: &str, + agent_id: &str, + provider: &str, + model_id: &str, +) -> Result<(), String> { + request( + socket_path, + "set_model", + Some(agent_id), + Some(json!({ "provider": provider, "modelId": model_id })), + ) + .await + .map(|_| ()) +} + +pub async fn set_thinking_level( + socket_path: &str, + agent_id: &str, + level: &str, +) -> Result<(), String> { + request( + socket_path, + "set_thinking_level", + Some(agent_id), + Some(json!({ "level": level })), + ) + .await + .map(|_| ()) +} + +pub async fn command(socket_path: &str, agent_id: &str, operation: &str) -> Result<(), String> { + request(socket_path, operation, Some(agent_id), None) + .await + .map(|_| ()) +} + +pub async fn respond_to_extension( + socket_path: &str, + agent_id: &str, + request_id: &str, + response: Value, +) -> Result<(), String> { + request( + socket_path, + "extension_response", + Some(agent_id), + Some(json!({ "requestId": request_id, "response": response })), + ) + .await + .map(|_| ()) +} + +pub async fn subscribe( + socket_path: String, + agent_id: String, + cursor: u64, + app: AppHandle, +) -> Result<(), String> { + let mut reader = connect_and_send( + &socket_path, + "subscribe", + Some(&agent_id), + Some(json!({ "cursor": cursor })), + ) + .await?; + loop { + let mut line = String::new(); + let bytes = reader + .read_line(&mut line) + .await + .map_err(|error| format!("Bridge subscription failed: {error}"))?; + if bytes == 0 { + return Err("Bridge subscription closed".to_owned()); + } + let value: Value = serde_json::from_str(&line) + .map_err(|error| format!("Bridge emitted invalid JSON: {error}"))?; + if value.get("id") == Some(&Value::String("tauri-ui".to_owned())) { + let result = result_from_response(value)?; + for event in result + .get("events") + .and_then(Value::as_array) + .into_iter() + .flatten() + { + app.emit("bridge-event", event) + .map_err(|error| format!("Could not publish bridge event: {error}"))?; + } + } else if value.get("type") == Some(&Value::String("event".to_owned())) { + if let Some(event) = value.get("event") { + app.emit("bridge-event", event) + .map_err(|error| format!("Could not publish bridge event: {error}"))?; + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tokio::net::UnixListener; + + #[tokio::test] + async fn sends_one_jsonl_request_and_unwraps_its_result() { + let path = format!( + "{}/pi-status-ui-{}.sock", + env::temp_dir().display(), + std::process::id() + ); + let _ = std::fs::remove_file(&path); + let listener = UnixListener::bind(&path).expect("listener"); + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await.expect("connection"); + let mut line = String::new(); + let mut reader = BufReader::new(stream); + reader.read_line(&mut line).await.expect("request"); + let request: Value = serde_json::from_str(&line).expect("JSON request"); + assert_eq!(request["op"], "submit_prompt"); + assert_eq!(request["agentId"], "agent-1"); + assert_eq!(request["payload"]["message"], "Hello Pi"); + let mut response = serde_json::to_vec(&json!({ + "id": "tauri-ui", + "ok": true, + "result": { "accepted": true } + })) + .expect("response JSON"); + response.push(b'\n'); + reader + .get_mut() + .write_all(&response) + .await + .expect("response"); + }); + let result = request( + &path, + "submit_prompt", + Some("agent-1"), + Some(json!({ "message": "Hello Pi" })), + ) + .await + .expect("request succeeds"); + assert_eq!(result["accepted"], true); + server.await.expect("server succeeds"); + std::fs::remove_file(path).expect("socket cleanup"); + } + + #[tokio::test] + async fn loads_state_history_commands_and_models_as_one_snapshot() { + let path = format!( + "{}/pi-status-ui-load-{}.sock", + env::temp_dir().display(), + std::process::id() + ); + let _ = std::fs::remove_file(&path); + let listener = UnixListener::bind(&path).expect("listener"); + let server = tokio::spawn(async move { + for _ in 0..5 { + let (stream, _) = listener.accept().await.expect("connection"); + tokio::spawn(async move { + let mut line = String::new(); + let mut reader = BufReader::new(stream); + reader.read_line(&mut line).await.expect("request"); + let request: Value = serde_json::from_str(&line).expect("JSON request"); + assert_eq!(request["agentId"], "agent-1"); + let result = match request["op"].as_str() { + Some("get_state") => json!({ "data": { "isStreaming": false } }), + Some("get_session_stats") => json!({ + "data": { "contextUsage": { "tokens": 32000, "contextWindow": 200000 } } + }), + Some("get_transcript") => json!({ "data": { "messages": [] } }), + Some("get_commands") => json!({ "data": { "commands": [] } }), + Some("get_available_models") => json!({ "data": { "models": [] } }), + other => panic!("unexpected operation: {other:?}"), + }; + let mut response = serde_json::to_vec(&json!({ + "id": "tauri-ui", "ok": true, "result": result + })) + .expect("response JSON"); + response.push(b'\n'); + reader.get_mut().write_all(&response).await.expect("response"); + }); + } + }); + let snapshot = load_agent(&path, "agent-1").await.expect("snapshot"); + assert_eq!(snapshot["state"]["data"]["isStreaming"], false); + assert_eq!(snapshot["stats"]["data"]["contextUsage"]["tokens"], 32000); + assert_eq!(snapshot["transcript"]["data"]["messages"], json!([])); + server.await.expect("server succeeds"); + std::fs::remove_file(path).expect("socket cleanup"); + } +} diff --git a/ui/src-tauri/src/lib.rs b/ui/src-tauri/src/lib.rs new file mode 100644 index 0000000..c3e6fcc --- /dev/null +++ b/ui/src-tauri/src/lib.rs @@ -0,0 +1,151 @@ +mod bridge; + +use serde_json::Value; +use std::sync::Mutex; +use tauri::{async_runtime::JoinHandle, AppHandle, Emitter, Manager, State}; + +struct Subscription(Mutex>>); + +fn socket_path() -> Result { + bridge::default_socket_path() +} + +#[tauri::command] +async fn list_agents() -> Result { + bridge::list_agents(&socket_path()?).await +} + +#[tauri::command] +async fn list_directories() -> Result { + bridge::list_directories(&socket_path()?).await +} + +#[tauri::command] +async fn select_worktree(worktree_path: String) -> Result { + bridge::select_worktree(&socket_path()?, &worktree_path).await +} + +#[tauri::command] +async fn load_agent(agent_id: String) -> Result { + bridge::load_agent(&socket_path()?, &agent_id).await +} + +#[tauri::command] +async fn list_sessions(agent_id: String) -> Result { + bridge::list_sessions(&socket_path()?, &agent_id).await +} + +#[tauri::command] +async fn switch_session(agent_id: String, session_path: String) -> Result { + bridge::switch_session(&socket_path()?, &agent_id, &session_path).await +} + +#[tauri::command] +async fn new_session(agent_id: String) -> Result { + bridge::new_session(&socket_path()?, &agent_id).await +} + +#[tauri::command] +async fn submit_prompt(agent_id: String, message: String) -> Result<(), String> { + bridge::submit_prompt(&socket_path()?, &agent_id, &message).await +} + +#[tauri::command] +async fn abort(agent_id: String) -> Result<(), String> { + bridge::abort(&socket_path()?, &agent_id).await +} + +#[tauri::command] +async fn retry(agent_id: String) -> Result<(), String> { + bridge::command(&socket_path()?, &agent_id, "retry").await +} + +#[tauri::command] +async fn restart(agent_id: String) -> Result<(), String> { + bridge::command(&socket_path()?, &agent_id, "restart").await +} + +#[tauri::command] +async fn set_model(agent_id: String, provider: String, model_id: String) -> Result<(), String> { + bridge::set_model(&socket_path()?, &agent_id, &provider, &model_id).await +} + +#[tauri::command] +async fn set_thinking_level(agent_id: String, level: String) -> Result<(), String> { + bridge::set_thinking_level(&socket_path()?, &agent_id, &level).await +} + +#[tauri::command] +async fn respond_to_extension( + agent_id: String, + request_id: String, + response: Value, +) -> Result<(), String> { + bridge::respond_to_extension(&socket_path()?, &agent_id, &request_id, response).await +} + +#[tauri::command] +fn subscribe_agent( + app: AppHandle, + subscriptions: State<'_, Subscription>, + agent_id: String, + cursor: u64, +) -> Result<(), String> { + let socket = socket_path()?; + if let Some(subscription) = subscriptions + .0 + .lock() + .map_err(|_| "Could not update bridge subscription".to_owned())? + .take() + { + subscription.abort(); + } + let event_app = app.clone(); + let task = tauri::async_runtime::spawn(async move { + if let Err(error) = bridge::subscribe(socket, agent_id.clone(), cursor, event_app.clone()).await { + let _ = event_app.emit( + "bridge-error", + serde_json::json!({ "agentId": agent_id, "message": error }), + ); + } + }); + let mut current_subscription = subscriptions + .0 + .lock() + .map_err(|_| "Could not retain bridge subscription".to_owned())?; + *current_subscription = Some(task); + Ok(()) +} + +#[cfg_attr(mobile, tauri::mobile_entry_point)] +pub fn run() { + let builder = tauri::Builder::default() + .manage(Subscription(Mutex::new(None))) + .plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| { + if let Some(window) = app.get_webview_window("main") { + let _ = window.show(); + let _ = window.set_focus(); + } + })) + .invoke_handler(tauri::generate_handler![ + list_agents, + list_directories, + select_worktree, + load_agent, + list_sessions, + switch_session, + new_session, + submit_prompt, + abort, + retry, + restart, + set_model, + set_thinking_level, + respond_to_extension, + subscribe_agent + ]); + let context = tauri::generate_context!(); + if let Err(error) = builder.run(context) { + eprintln!("Pi Status UI exited: {error}"); + } +} diff --git a/ui/src-tauri/src/main.rs b/ui/src-tauri/src/main.rs new file mode 100644 index 0000000..099b1d6 --- /dev/null +++ b/ui/src-tauri/src/main.rs @@ -0,0 +1,6 @@ +// Prevents additional console window on Windows in release, DO NOT REMOVE!! +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] + +fn main() { + pi_status_ui_lib::run() +} diff --git a/ui/src-tauri/tauri.conf.json b/ui/src-tauri/tauri.conf.json new file mode 100644 index 0000000..cc3ba3c --- /dev/null +++ b/ui/src-tauri/tauri.conf.json @@ -0,0 +1,43 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "productName": "Pi Status UI", + "version": "0.1.0", + "identifier": "com.alex.pi-status-ui", + "build": { + "beforeDevCommand": "npm run dev", + "devUrl": "http://localhost:1420", + "beforeBuildCommand": "npm run build", + "frontendDist": "../dist" + }, + "app": { + "windows": [ + { + "title": "Pi Status UI", + "width": 760, + "height": 620, + "minWidth": 480, + "minHeight": 420, + "center": true, + "decorations": false, + "transparent": true, + "alwaysOnTop": true, + "skipTaskbar": true, + "shadow": true + } + ], + "security": { + "csp": null + } + }, + "bundle": { + "active": true, + "targets": "all", + "icon": [ + "icons/32x32.png", + "icons/128x128.png", + "icons/128x128@2x.png", + "icons/icon.icns", + "icons/icon.ico" + ] + } +} diff --git a/ui/src/App.css b/ui/src/App.css new file mode 100644 index 0000000..547b032 --- /dev/null +++ b/ui/src/App.css @@ -0,0 +1,778 @@ +@property --transcript-border-angle { + syntax: ""; + inherits: false; + initial-value: 0deg; +} + +:root { + font-family: Inter, ui-sans-serif, system-ui, sans-serif; + color: #ebebe8; + background: #101111; +} +* { + box-sizing: border-box; +} +body { + margin: 0; + min-width: 480px; + min-height: 420px; + background: transparent; +} +button, +textarea, +input, +select { + font: inherit; +} +button { + cursor: pointer; + border: 0; + border-radius: 7px; + padding: 8px 12px; + background: #dcecc8; + color: #152012; + font-size: 12px; + font-weight: 750; +} +button:hover { + background: #f0ffe0; +} + +.app-shell { + position: relative; + height: 100vh; + max-height: 100vh; + min-height: 0; + overflow: hidden; + display: flex; + flex-direction: column; + gap: 9px; + padding: 14px; + border: 1px solid #3b403a; + border-radius: 14px; + background: rgba(18, 20, 19, 0.97); + box-shadow: 0 20px 48px rgba(0, 0, 0, 0.48); + backdrop-filter: blur(18px); +} + +.workflow-header { + display: flex; + cursor: grab; + user-select: none; + align-items: center; + justify-content: space-between; + gap: 12px; + min-width: 0; +} +.workspace-summary { + min-width: 0; +} +.workspace-heading, +.status-row, +.composer-actions, +.settings-actions, +.folder-form { + display: flex; + align-items: center; + gap: 8px; +} +.workspace-heading { + min-width: 0; +} +.brand { + flex: 0 0 auto; + color: #b8df84; + font-size: 10px; + font-weight: 900; + letter-spacing: 0.16em; +} +h1 { + min-width: 0; + margin: 0; + font-size: 16px; + line-height: 1.15; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.status-row { + margin-top: 3px; + color: #898f89; + font-size: 11px; + white-space: nowrap; + overflow: hidden; +} +.status-row > span { + overflow: hidden; + text-overflow: ellipsis; +} +.extension-status { + color: #b8df84; +} +.status-metric { + flex: 0 0 auto; + border-left: 1px solid #3a413a; + padding-left: 6px; + color: #b9c4ae; + font-variant-numeric: tabular-nums; +} +.state-pill { + flex: 0 0 auto; + border: 1px solid #404740; + border-radius: 999px; + padding: 2px 6px; + color: #afb3ad; + background: #202320; + font-size: 10px; + font-weight: 800; +} +.state-pill.working { + border-color: #628b42; + color: #c4ed8b; +} +.state-pill.recovering { + border-color: #8a713d; + color: #f0ce83; +} +.state-pill.error { + border-color: #8b4c4c; + color: #ffadad; +} +.workflow-header button, +.workflow-header input, +.workflow-header select { + cursor: pointer; +} +.compact-button { + flex: 0 0 auto; + padding: 7px 9px; +} + +.agents { + display: flex; + align-items: center; + gap: 6px; + min-height: 30px; + overflow-x: auto; + padding: 1px 0 3px; +} +.agent-caption { + flex: 0 0 auto; + color: #70776f; + font-size: 10px; + font-weight: 800; + letter-spacing: 0.08em; + text-transform: uppercase; +} +.agent { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; + max-width: 180px; + padding: 5px 8px; + border: 1px solid #303530; + background: #1b1e1b; + color: #aeb4ac; + font-size: 11px; + font-weight: 650; +} +.agent > span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.agent small { + color: #70776f; + font-size: 10px; + font-weight: 600; +} +.agent.selected { + border-color: #7da856; + background: #222b1d; + color: #ecf7df; +} + +.notifications { + display: grid; + gap: 5px; +} +.notification { + border-left: 3px solid #79b8d8; + border-radius: 5px; + padding: 6px 8px; + background: #1c292e; + color: #cbe7f2; + font-size: 11px; +} +.notification.warning { + border-color: #d3ad54; + background: #332c1a; + color: #ffe4a0; +} +.notification.error { + border-color: #db7777; + background: #332020; + color: #ffc0c0; +} + +.workflow { + flex: 1 1 auto; + min-height: 0; + overflow: hidden; + display: grid; + grid-template-rows: minmax(0, 1fr) auto; + gap: 7px; +} +.workflow-main { + min-height: 0; + display: grid; + grid-template-columns: minmax(0, 1fr) 210px; + gap: 8px; +} +.transcript, +.settings { + min-height: 0; + overflow: auto; + border: 1px solid #2d332e; + border-radius: 9px; + background: #151716; +} +.transcript { + padding: 8px 11px; + overscroll-behavior: contain; + scroll-padding-bottom: 112px; +} +.transcript.with-extension { + padding-bottom: 112px; +} +.transcript.working { + border-color: transparent; + background: + linear-gradient(#151716, #151716) padding-box, + conic-gradient( + from var(--transcript-border-angle), + #314426, + #b8df84, + #314426 28%, + #314426 72%, + #b8df84, + #314426 + ) + border-box; + box-shadow: 0 0 14px rgba(151, 207, 104, 0.14); + animation: transcript-border-orbit 2.4s linear infinite; +} +.transcript.recovering { + border-color: #a8823f; + box-shadow: 0 0 12px rgba(240, 199, 110, 0.12); +} +.transcript.error { + border-color: #9d5050; +} +@keyframes transcript-border-orbit { + to { + --transcript-border-angle: 360deg; + } +} +.todos-pane { + min-height: 0; + overflow: auto; + border: 1px solid #303830; + border-radius: 9px; + background: #181c18; + padding: 8px; +} +.todos-heading { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 7px; + color: #dce6d5; + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.08em; +} +.todos-heading span { + border-radius: 999px; + padding: 1px 6px; + background: #2b3826; + color: #b8df84; + font-size: 10px; +} +.todo-list { + display: grid; + gap: 5px; + margin: 0; + padding: 0; + list-style: none; +} +.todo-list li { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 6px; + align-items: start; + padding: 5px 0; + border-bottom: 1px solid #293029; +} +.todo-list li:last-child { + border-bottom: 0; +} +.todo-list li > div { + display: grid; + gap: 2px; + min-width: 0; +} +.todo-list li strong { + overflow: hidden; + color: #d9dfd5; + font-size: 11px; + text-overflow: ellipsis; + white-space: nowrap; +} +.todo-list li small { + color: #7f897e; + font-size: 10px; +} +.todo-list li > span { + color: #9ea69d; + font-size: 9px; + text-transform: capitalize; +} +.todo-list li.in_progress > span { + color: #b8df84; +} +.todo-list li.completed { + opacity: 0.55; +} +.todo-list li.completed strong { + text-decoration: line-through; +} +.message { + margin: 4px 0; + padding: 7px 8px; + border: 1px solid transparent; + border-radius: 7px; +} +.message.user { + margin-left: 34px; + border-color: #30435b; + background: #192536; +} +.message.assistant { + margin-right: 18px; + border-color: #344633; + background: #1a241a; +} +.message.system, +.message.toolResult { + border-bottom: 1px solid #282d28; + border-radius: 0; +} +.message:last-child { + border-bottom: 0; +} +.message strong { + display: block; + margin-bottom: 3px; + color: #b8df84; + font-size: 10px; + font-weight: 850; + letter-spacing: 0.08em; + text-transform: uppercase; +} +.message.user strong { + color: #8cc3ff; +} +.pending-message { + margin: 5px -3px 0; + padding: 7px 8px; + border: 1px dashed #56825e; + border-radius: 7px; + background: #172017; +} +.pending-message strong { + display: flex; + align-items: center; + justify-content: space-between; +} +.pending-message strong span { + color: #b8df84; + font-size: 9px; + letter-spacing: 0.03em; + text-transform: none; +} +.pending-message.sending { + animation: pending-pulse 1.4s ease-in-out infinite; +} +@keyframes pending-pulse { + 50% { + border-color: #91ba69; + } +} +.message pre { + margin: 0; + white-space: pre-wrap; + overflow-wrap: anywhere; + font: inherit; + font-size: 12px; + line-height: 1.38; + color: #d7dbd5; +} +.muted { + margin: 0; + color: #858b85; + font-size: 12px; +} +.warning { + color: #ffb4aa; + font-weight: 700; +} + +.workflow-footer { + display: grid; + gap: 5px; +} +.pi-controls { + display: grid; + grid-template-columns: minmax(0, 1fr) 150px; + gap: 7px; + padding: 4px 6px; + border: 1px solid #303730; + border-radius: 7px; + background: #191d19; + color: #8f988e; + font-size: 9px; +} +.pi-controls label { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 5px; + align-items: center; + font-weight: 800; + white-space: nowrap; +} +.pi-controls select { + min-width: 0; + padding: 2px 4px; + border-radius: 4px; + font-size: 10px; + font-weight: 600; + color-scheme: dark; +} +.composer-stack { + display: grid; + gap: 6px; + min-width: 0; +} +.composer { + display: grid; + gap: 5px; +} +.composer > label { + color: #939b92; + font-size: 10px; + font-weight: 800; + letter-spacing: 0.06em; + text-transform: uppercase; +} +input, +select, +textarea { + width: 100%; + border: 1px solid #3a423a; + border-radius: 7px; + padding: 7px 9px; + color: #e8ebe6; + background: #1a1d1a; +} +input:focus, +select:focus, +textarea:focus { + outline: 2px solid #8ebd5f; + outline-offset: 1px; +} +textarea { + min-height: 48px; + max-height: 100px; + resize: vertical; + font-size: 12px; + line-height: 1.35; +} +.composer textarea:focus { + outline: none; + border-color: #6f8b56; + box-shadow: 0 0 0 1px rgba(143, 189, 95, 0.28); +} +.composer-actions { + justify-content: flex-end; + min-width: 0; +} +.composer-actions > button { + flex: 0 0 auto; +} +.work-progress { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; + gap: 6px; + min-width: 0; + border: 1px solid #303630; + border-radius: 7px; + background: #191c19; + font-size: 10px; +} +.composer-progress { + flex: 1 1 auto; + padding: 5px 7px; +} +.work-indicator { + width: 7px; + height: 7px; + border-radius: 50%; + background: #656b65; +} +.work-copy { + display: grid; + min-width: 0; + gap: 1px; +} +.work-copy strong { + color: #dfe6dc; + font-size: 10px; +} +.work-copy span, +.work-meta { + color: #8e968d; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.work-meta { + max-width: 58px; + font-size: 10px; + text-align: right; +} +.work-progress.working { + border-color: #547339; + background: #1a2217; +} +.work-progress.working .work-indicator { + background: #b8df84; + animation: work-pulse 1.2s ease-in-out infinite; +} +.work-progress.recovering .work-indicator { + background: #f0c76e; + animation: work-pulse 1.2s ease-in-out infinite; +} +.work-progress.error .work-indicator { + background: #ff8e8e; +} +@keyframes work-pulse { + 50% { + opacity: 0.35; + transform: scale(0.72); + } +} + +.quiet { + background: #282d28; + color: #dce1da; +} +.danger { + color: #ffb0b0; +} +.commands { + display: grid; + gap: 4px; + max-height: 80px; + overflow: auto; +} +.command-followup { + display: grid; + gap: 6px; + border: 1px solid #4b6636; + border-radius: 8px; + background: #1b2518; + padding: 7px; +} +.command-followup-heading { + display: flex; + align-items: center; + justify-content: space-between; + color: #c4e89a; + font-size: 11px; +} +.command-followup-heading button { + padding: 4px 7px; + font-size: 10px; +} +.command-choice-list { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: 5px; + max-height: 130px; + overflow: auto; +} +.command-choice-list button { + padding: 6px 8px; + text-align: left; + font-size: 11px; +} +.commands button { + display: grid; + grid-template-columns: 116px minmax(0, 1fr); + gap: 8px; + padding: 5px 8px; + text-align: left; + font-size: 11px; +} +.commands small { + overflow: hidden; + color: #969d95; + font-weight: 500; + text-overflow: ellipsis; + white-space: nowrap; +} +.extension-widget { + border-left: 2px solid #9cc76d; + border-radius: 3px; + background: #1b2417; + padding: 5px 7px; + font-size: 11px; +} +.extension-widget p { + margin: 2px 0; + white-space: pre-wrap; +} + +.settings { + flex: 1 1 auto; + display: grid; + align-content: start; + gap: 10px; + padding: 12px; +} +h2 { + margin: 0; + font-size: 14px; +} +.folder-form { + align-items: stretch; +} +.remembered { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; +} +.settings-actions { + justify-content: flex-start; +} +.session-panel { + display: grid; + gap: 8px; + border-top: 1px solid #303830; + padding-top: 10px; +} +.session-panel-heading { + display: flex; + align-items: start; + justify-content: space-between; + gap: 8px; +} +.session-panel-heading h2 { + margin-bottom: 3px; +} +.session-panel-heading .muted { + max-width: 460px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.session-list { + display: grid; + gap: 5px; + max-height: 230px; + overflow: auto; +} +.session { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 9px; + align-items: center; + width: 100%; + padding: 7px 9px; + text-align: left; +} +.session > span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.session small { + color: #8f988e; + font-size: 10px; +} +.session.current { + border: 1px solid #769e53; + background: #202b1c; + color: #e7f2de; + cursor: default; +} +.session:disabled { + opacity: 1; +} +.session.quiet:disabled { + opacity: 0.5; + cursor: not-allowed; +} +.extension { + position: absolute; + inset: auto 14px 14px; + max-height: min(70vh, 460px); + overflow: auto; + border: 1px solid #9cc76d; + border-radius: 10px; + background: #1d2818; + box-shadow: 0 18px 42px rgba(0, 0, 0, 0.5); + padding: 13px; + display: grid; + gap: 8px; +} +.extension p { + margin: 0; + font-size: 12px; +} + +@media (prefers-reduced-motion: reduce) { + .transcript.working { + animation: none; + } +} + +@media (max-width: 680px) { + .app-shell { + padding: 11px; + gap: 7px; + border-radius: 10px; + } + .workflow-main { + grid-template-columns: minmax(0, 1fr) 170px; + } + .pi-controls { + grid-template-columns: minmax(0, 1fr) 120px; + } + .transcript { + padding: 7px 9px; + } + .composer-actions { + gap: 5px; + } + .composer-progress .work-copy span { + display: none; + } + .work-meta { + display: none; + } +} diff --git a/ui/src/App.tsx b/ui/src/App.tsx new file mode 100644 index 0000000..b58f662 --- /dev/null +++ b/ui/src/App.tsx @@ -0,0 +1,1290 @@ +import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { listen } from "@tauri-apps/api/event"; +import { invoke } from "@tauri-apps/api/core"; +import { getCurrentWindow } from "@tauri-apps/api/window"; +import "./App.css"; + +type Agent = { id: string; worktreePath: string; state: string }; +type Directory = { + worktreePath: string; + state: string; + agentId?: string; +}; +type Session = { + path: string; + id: string; + cwd: string; + name?: string; + parentSessionPath?: string; + created?: string; + modified: string; + messageCount: number; + firstMessage?: string; + isCurrent: boolean; +}; +type AgentState = { + isStreaming?: boolean; + thinkingLevel?: string; + sessionName?: string; + sessionId?: string; + messageCount?: number; + pendingMessageCount?: number; + model?: { + provider?: string; + id?: string; + name?: string; + contextWindow?: number; + maxTokens?: number; + }; +}; +type TodoTask = { + id: number; + subject: string; + description?: string; + activeForm?: string; + status: "pending" | "in_progress" | "completed" | "deleted"; +}; +type SessionStats = { + tokens?: { + input?: number; + output?: number; + cacheRead?: number; + cacheWrite?: number; + total?: number; + }; + contextUsage?: { + tokens?: number | null; + contextWindow?: number; + percent?: number | null; + }; +}; +type Message = { + role?: string; + toolName?: string; + details?: { tasks?: TodoTask[] }; + content?: + | string + | Array<{ type?: string; text?: string; thinking?: string; name?: string }>; +}; +type Model = { provider?: string; id?: string; name?: string }; +type FollowUpCommand = "model" | "resume" | "thinking"; +type Command = { + name?: string; + description?: string; + followUp?: FollowUpCommand; +}; +type Extension = { + id?: string; + method?: string; + title?: string; + message?: string; + options?: string[]; + placeholder?: string; + initialValue?: string; + text?: string; + notifyType?: "info" | "warning" | "error"; + statusKey?: string; + statusText?: string; + widgetKey?: string; + widgetLines?: string[]; + widgetPlacement?: "aboveEditor" | "belowEditor"; +}; +type ExtensionWidget = { + lines: string[]; + placement: "aboveEditor" | "belowEditor"; +}; +type BridgeEvent = { + agentId?: string; + seq?: number; + type?: string; + data?: { state?: string; event?: Record }; +}; +type WorkProgress = { + phase: "idle" | "working" | "recovering" | "error"; + detail: string; + tool?: string; + queueCount?: number; + toolCount: number; +}; +type PendingSubmission = { + id: number; + text: string; + userMessageCount: number; + phase: "sending" | "sent"; +}; + +const thinkingLevels = [ + "off", + "minimal", + "low", + "medium", + "high", + "xhigh", + "max", +]; +const rememberedKey = "pi-status-ui.remembered-worktrees"; +const selectedPathKey = "pi-status-ui.selected-worktree"; +const controlCommands: Command[] = [ + { + name: "model", + description: "Choose a configured model", + followUp: "model", + }, + { + name: "thinking", + description: "Choose a thinking level", + followUp: "thinking", + }, + { name: "resume", description: "Choose a saved session", followUp: "resume" }, +]; + +function unpack(value: unknown): Record { + return value && typeof value === "object" && "data" in value + ? ((value as { data: Record }).data ?? {}) + : ((value as Record) ?? {}); +} + +function messageText(message: Message) { + if (typeof message.content === "string") return message.content; + return (message.content ?? []) + .map((part) => { + if (part.type === "text") return part.text ?? ""; + if (part.type === "thinking") return `Thinking: ${part.thinking ?? ""}`; + if (part.type === "toolCall") return `Tool: ${part.name ?? "running"}`; + return ""; + }) + .filter(Boolean) + .join("\n"); +} + +function stripAnsi(value: string) { + return value.replace(/\u001b\[[0-?]*[ -/]*[@-~]/g, ""); +} + +function worktreeLabel(path?: string) { + if (!path) return undefined; + const segments = path.split("/").filter(Boolean); + return segments[segments.length - 1] ?? path; +} + +function formatTokens(value?: number | null) { + if (value === undefined || value === null) return "—"; + if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}m`; + if (value >= 1_000) return `${Math.round(value / 1_000)}k`; + return String(value); +} + +function currentTodos(messages: Message[]) { + for (let index = messages.length - 1; index >= 0; index -= 1) { + const message = messages[index]; + if ( + message.role === "toolResult" && + message.toolName === "todo" && + Array.isArray(message.details?.tasks) + ) { + return message.details.tasks + .filter((task) => task.status !== "deleted") + .sort((left, right) => { + const order: Record = { + in_progress: 0, + pending: 1, + completed: 2, + deleted: 3, + }; + return order[left.status] - order[right.status]; + }); + } + } + return []; +} + +function rememberedPaths() { + try { + return JSON.parse(localStorage.getItem(rememberedKey) ?? "[]") as string[]; + } catch { + return []; + } +} + +function App() { + const [agents, setAgents] = useState([]); + const [directories, setDirectories] = useState([]); + const [sessions, setSessions] = useState([]); + const [selectedId, setSelectedId] = useState(); + const [state, setState] = useState({}); + const [stats, setStats] = useState({}); + const [messages, setMessages] = useState([]); + const [pendingSubmissions, setPendingSubmissions] = useState< + PendingSubmission[] + >([]); + const [models, setModels] = useState([]); + const [commands, setCommands] = useState([]); + const [message, setMessage] = useState(""); + const [folderPath, setFolderPath] = useState(""); + const [status, setStatus] = useState("Connecting to Pi Status Bridge…"); + const [view, setView] = useState<"conversation" | "settings">("conversation"); + const [commandFollowUp, setCommandFollowUp] = useState(); + const [pendingExtension, setPendingExtension] = useState(); + const [extensionValue, setExtensionValue] = useState(""); + const [extensionTitle, setExtensionTitle] = useState(); + const [extensionNotifications, setExtensionNotifications] = useState< + Extension[] + >([]); + const [extensionStatuses, setExtensionStatuses] = useState< + Record + >({}); + const [extensionWidgets, setExtensionWidgets] = useState< + Record + >({}); + const transcriptRef = useRef(null); + const pendingSubmissionId = useRef(0); + const loadGeneration = useRef(0); + const lastEventSequence = useRef>({}); + const [workProgress, setWorkProgress] = useState({ + phase: "idle", + detail: "Ready for your next prompt", + toolCount: 0, + }); + + const selected = agents.find((agent) => agent.id === selectedId); + const filteredCommands = useMemo(() => { + if (!message.startsWith("/")) return []; + const uniqueCommands = [...controlCommands, ...commands].filter( + (command, index, all) => + all.findIndex((candidate) => candidate.name === command.name) === index, + ); + return uniqueCommands + .filter((command) => `/${command.name ?? ""}`.startsWith(message)) + .slice(0, 8); + }, [commands, message]); + const todos = useMemo(() => currentTodos(messages), [messages]); + const contextTokens = stats.contextUsage?.tokens; + const contextWindow = + stats.contextUsage?.contextWindow ?? state.model?.contextWindow; + const contextPercent = stats.contextUsage?.percent; + const contextPercentLabel = + contextPercent === undefined || contextPercent === null + ? undefined + : contextPercent.toFixed(1); + const cacheTokens = + (stats.tokens?.cacheRead ?? 0) + (stats.tokens?.cacheWrite ?? 0); + + async function loadAgent(agentId: string, startSubscription = false) { + const generation = ++loadGeneration.current; + try { + const snapshot = await invoke>("load_agent", { + agentId, + }); + if (generation !== loadGeneration.current) return; + const loadedState = unpack(snapshot.state); + const loadedStats = unpack(snapshot.stats); + const loadedTranscript = unpack(snapshot.transcript); + const loadedCommands = unpack(snapshot.commands); + const loadedModels = unpack(snapshot.models); + setState(loadedState as AgentState); + setStats(loadedStats as SessionStats); + setMessages((loadedTranscript.messages as Message[] | undefined) ?? []); + setCommands((loadedCommands.commands as Command[] | undefined) ?? []); + setModels((loadedModels.models as Model[] | undefined) ?? []); + setStatus(loadedState.isStreaming ? "Pi working" : "Pi ready"); + setWorkProgress( + loadedState.isStreaming + ? (current) => ({ + ...current, + phase: "working", + detail: "Thinking and preparing a response", + }) + : { + phase: "idle", + detail: "Ready for your next prompt", + toolCount: 0, + }, + ); + if (startSubscription) + await invoke("subscribe_agent", { agentId, cursor: 0 }); + } catch (error) { + setStatus(String(error)); + } + } + + async function refreshAgents( + preferredPath = localStorage.getItem(selectedPathKey) ?? undefined, + ) { + try { + const [agentResponse, directoryResponse] = await Promise.all([ + invoke<{ agents: Agent[] }>("list_agents"), + invoke<{ directories: Directory[] }>("list_directories"), + ]); + setAgents(agentResponse.agents); + setDirectories(directoryResponse.directories); + const next = + agentResponse.agents.find( + (agent) => agent.worktreePath === preferredPath, + ) ?? + agentResponse.agents.find((agent) => agent.id === selectedId) ?? + agentResponse.agents[0]; + setSelectedId(next?.id); + if (next) { + localStorage.setItem(selectedPathKey, next.worktreePath); + await loadAgent(next.id, true); + } else setStatus("No Pi agents available"); + } catch (error) { + setStatus(String(error)); + } + } + + async function loadSessions(agentId: string) { + try { + const response = await invoke<{ sessions: Session[] }>("list_sessions", { + agentId, + }); + setSessions(response.sessions); + } catch (error) { + setStatus(String(error)); + } + } + + useEffect(() => { + void refreshAgents(); + }, []); + + useEffect(() => { + if (selectedId) void loadSessions(selectedId); + else setSessions([]); + }, [selectedId]); + + useLayoutEffect(() => { + const scrollToLatest = () => { + const transcript = transcriptRef.current; + if (transcript) transcript.scrollTop = transcript.scrollHeight; + }; + scrollToLatest(); + const frame = requestAnimationFrame(scrollToLatest); + return () => cancelAnimationFrame(frame); + }, [messages, pendingSubmissions, selectedId]); + + useEffect(() => { + const userMessages = messages.filter((entry) => entry.role === "user"); + setPendingSubmissions((current) => + current.filter((pending) => { + const receivedMessages = userMessages.slice(pending.userMessageCount); + return !receivedMessages.some( + (entry) => messageText(entry) === pending.text, + ); + }), + ); + }, [messages]); + + function toolLabel(event: Record) { + const tool = event.toolName ?? event.tool ?? event.name ?? event.toolCall; + if (typeof tool === "string") return tool; + if ( + tool && + typeof tool === "object" && + "name" in tool && + typeof tool.name === "string" + ) + return tool.name; + return "tool"; + } + + function updateWorkProgress(event: BridgeEvent) { + const data = event.data ?? {}; + const payload = data.event ?? {}; + if (event.type === "agent_state") { + const nextState = data.state; + if (nextState === "streaming") + setWorkProgress((current) => ({ + ...current, + phase: "working", + detail: "Thinking and preparing a response", + })); + else if (nextState === "recovering") + setWorkProgress((current) => ({ + ...current, + phase: "recovering", + detail: "Recovering the Pi session", + })); + else if (nextState === "error") + setWorkProgress((current) => ({ + ...current, + phase: "error", + detail: "Pi needs attention", + })); + else if (nextState === "idle") + setWorkProgress({ + phase: "idle", + detail: "Ready for your next prompt", + toolCount: 0, + }); + return; + } + if (event.type === "tool") { + const eventType = typeof payload.type === "string" ? payload.type : ""; + const label = toolLabel(payload); + setWorkProgress((current) => { + if (current.phase !== "working") return current; + if (eventType.endsWith("_end")) + return { + ...current, + detail: "Continuing after tool result", + tool: undefined, + }; + return { + ...current, + detail: eventType.endsWith("_update") + ? "Tool is reporting progress" + : "Running a tool", + tool: label, + toolCount: current.toolCount + (eventType.endsWith("_start") ? 1 : 0), + }; + }); + return; + } + if (event.type === "queue") { + const queueCount = Array.isArray(payload.queue) + ? payload.queue.length + : typeof payload.pendingMessageCount === "number" + ? payload.pendingMessageCount + : undefined; + setWorkProgress((current) => ({ + ...current, + queueCount, + detail: queueCount + ? `${queueCount} follow-up${queueCount === 1 ? "" : "s"} queued` + : current.detail, + })); + return; + } + if (event.type === "stream") + setWorkProgress((current) => + current.phase === "idle" + ? current + : { + ...current, + phase: "working", + detail: current.tool ? current.detail : "Generating response", + }, + ); + } + + function handleExtension(extension?: Extension) { + if (!extension) return; + switch (extension.method) { + case "select": + case "confirm": + case "input": + case "editor": + setPendingExtension(extension); + setExtensionValue(extension.initialValue ?? extension.text ?? ""); + break; + case "notify": + setExtensionNotifications((current) => + [ + ...current, + { ...extension, message: stripAnsi(extension.message ?? "") }, + ].slice(-4), + ); + break; + case "setStatus": { + const statusKey = extension.statusKey; + if (statusKey) + setExtensionStatuses((current) => { + const next = { ...current }; + if (extension.statusText) + next[statusKey] = stripAnsi(extension.statusText); + else delete next[statusKey]; + return next; + }); + break; + } + case "setWidget": { + const widgetKey = extension.widgetKey; + if (widgetKey) + setExtensionWidgets((current) => { + const next = { ...current }; + if (extension.widgetLines) + next[widgetKey] = { + lines: extension.widgetLines.map(stripAnsi), + placement: extension.widgetPlacement ?? "aboveEditor", + }; + else delete next[widgetKey]; + return next; + }); + break; + } + case "setTitle": + setExtensionTitle( + extension.title ? stripAnsi(extension.title) : undefined, + ); + break; + case "set_editor_text": + setMessage(stripAnsi(extension.text ?? "")); + break; + } + } + + useEffect(() => { + let unlisten: (() => void) | undefined; + let unlistenError: (() => void) | undefined; + void listen("bridge-event", (event) => { + const bridgeEvent = event.payload; + if (bridgeEvent.agentId !== selectedId) return; + if ( + bridgeEvent.agentId && + bridgeEvent.seq !== undefined && + bridgeEvent.seq <= (lastEventSequence.current[bridgeEvent.agentId] ?? 0) + ) + return; + if (bridgeEvent.agentId && bridgeEvent.seq !== undefined) + lastEventSequence.current[bridgeEvent.agentId] = bridgeEvent.seq; + if (bridgeEvent.type === "extension_ui_request") { + handleExtension(bridgeEvent.data?.event as Extension | undefined); + } else { + updateWorkProgress(bridgeEvent); + if (selectedId) void loadAgent(selectedId); + } + }).then((stop) => { + unlisten = stop; + }); + void listen<{ message?: string }>("bridge-error", (event) => { + setStatus( + event.payload.message ?? "Bridge connection lost; reconnecting…", + ); + window.setTimeout(() => void refreshAgents(), 500); + }).then((stop) => { + unlistenError = stop; + }); + return () => { + unlisten?.(); + unlistenError?.(); + }; + }, [selectedId]); + + async function chooseAgent(agent: Agent) { + setWorkProgress({ + phase: "idle", + detail: "Loading Pi session", + toolCount: 0, + }); + setSelectedId(agent.id); + localStorage.setItem(selectedPathKey, agent.worktreePath); + await loadAgent(agent.id, true); + } + + async function chooseDirectory(directory: Directory) { + const agent = agents.find( + (candidate) => candidate.id === directory.agentId, + ); + if (agent) await chooseAgent(agent); + else await activateWorktree(directory.worktreePath); + } + + async function startNewSession() { + if (!selectedId) return; + try { + const response = await invoke<{ data?: { cancelled?: boolean } }>( + "new_session", + { agentId: selectedId }, + ); + if (response.data?.cancelled) { + setStatus("New session was cancelled"); + return; + } + await loadAgent(selectedId, true); + await loadSessions(selectedId); + setCommandFollowUp(undefined); + setStatus("Started a new Pi session"); + } catch (error) { + setStatus(String(error)); + } + } + + async function switchSession(session: Session) { + if (!selectedId || session.isCurrent) return; + try { + const response = await invoke<{ data?: { cancelled?: boolean } }>( + "switch_session", + { agentId: selectedId, sessionPath: session.path }, + ); + if (response.data?.cancelled) { + setStatus("Session switch was cancelled"); + return; + } + await loadAgent(selectedId, true); + await loadSessions(selectedId); + setCommandFollowUp(undefined); + setStatus("Switched Pi session"); + } catch (error) { + setStatus(String(error)); + } + } + + async function openCommandFollowUp(command: FollowUpCommand) { + if (command === "resume" && state.isStreaming) { + setStatus("Wait for Pi to finish before switching sessions"); + return; + } + if (command === "resume" && selectedId) await loadSessions(selectedId); + setCommandFollowUp(command); + setMessage(""); + } + + async function chooseModel(model: Model) { + if (!model.provider || !model.id) return; + await invokeAgent("set_model", { + provider: model.provider, + modelId: model.id, + }); + setCommandFollowUp(undefined); + } + + async function chooseThinking(level: string) { + await invokeAgent("set_thinking_level", { level }); + setCommandFollowUp(undefined); + } + + async function activateWorktree(worktreePath: string) { + try { + const result = await invoke<{ agent: Agent }>("select_worktree", { + worktreePath, + }); + const paths = [...new Set([...rememberedPaths(), worktreePath])]; + localStorage.setItem(rememberedKey, JSON.stringify(paths)); + localStorage.setItem(selectedPathKey, worktreePath); + setFolderPath(""); + setSelectedId(result.agent.id); + await refreshAgents(worktreePath); + setView("conversation"); + } catch (error) { + setStatus(String(error)); + } + } + + async function addFolder() { + if (!folderPath.startsWith("/")) { + setStatus("Enter an absolute folder path"); + return; + } + await activateWorktree(folderPath); + } + + async function submit() { + const text = message.trim(); + if (!selectedId || !text) return; + const nativeCommand = controlCommands.find( + (command) => command.followUp && `/${command.name}` === text, + ); + if (nativeCommand?.followUp) { + await openCommandFollowUp(nativeCommand.followUp); + return; + } + const id = ++pendingSubmissionId.current; + setPendingSubmissions((current) => [ + ...current, + { + id, + text, + userMessageCount: messages.filter((entry) => entry.role === "user") + .length, + phase: "sending", + }, + ]); + setMessage(""); + setStatus("Sending prompt to Pi…"); + try { + await invoke("submit_prompt", { agentId: selectedId, message: text }); + setPendingSubmissions((current) => + current.map((pending) => + pending.id === id ? { ...pending, phase: "sent" } : pending, + ), + ); + setStatus("Prompt sent to Pi"); + } catch (error) { + setPendingSubmissions((current) => + current.filter((pending) => pending.id !== id), + ); + setMessage((current) => current || text); + setStatus(String(error)); + } + } + + async function invokeAgent( + command: string, + args: Record = {}, + ) { + if (!selectedId) return; + try { + await invoke(command, { agentId: selectedId, ...args }); + await loadAgent(selectedId); + } catch (error) { + setStatus(String(error)); + } + } + + useEffect(() => { + const handleKeydown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + event.preventDefault(); + event.stopPropagation(); + void getCurrentWindow() + .hide() + .catch((error) => setStatus(String(error))); + } + }; + window.addEventListener("keydown", handleKeydown, true); + return () => window.removeEventListener("keydown", handleKeydown, true); + }, []); + + async function respondToExtension(response: Record) { + if (!selectedId || !pendingExtension?.id) return; + try { + await invoke("respond_to_extension", { + agentId: selectedId, + requestId: pendingExtension.id, + response, + }); + setPendingExtension(undefined); + setExtensionValue(""); + } catch (error) { + setStatus(String(error)); + } + } + + const extensionMethod = pendingExtension?.method ?? ""; + const workLabel = + workProgress.phase === "recovering" + ? "Recovering" + : workProgress.phase === "error" + ? "Needs attention" + : workProgress.phase === "working" + ? "Working" + : "Ready"; + const workspaceLabel = + extensionTitle ?? worktreeLabel(selected?.worktreePath) ?? "Pi"; + return ( +
+
{ + if ( + event.button !== 0 || + (event.target as HTMLElement).closest( + "button, input, select, textarea", + ) + ) + return; + void getCurrentWindow() + .startDragging() + .catch((error) => setStatus(String(error))); + }} + > +
+
+ PI +

+ {workspaceLabel} +

+ + {workLabel} + +
+
+ {status} + {contextWindow && ( + + ctx {formatTokens(contextTokens)}/{formatTokens(contextWindow)} + {contextPercentLabel ? ` · ${contextPercentLabel}%` : ""} + + )} + {stats.tokens?.total !== undefined && ( + + tok {formatTokens(stats.tokens.total)} · in{" "} + {formatTokens(stats.tokens.input)} · out{" "} + {formatTokens(stats.tokens.output)} · cache{" "} + {formatTokens(cacheTokens)} + + )} + {Object.values(extensionStatuses).map((entry) => ( + + {entry} + + ))} +
+
+ +
+ +
+ Directories + {directories.map((directory) => ( + + ))} +
+ + {extensionNotifications.length > 0 && ( +
+ {extensionNotifications.map((notice, index) => ( +
+ {notice.message ?? "Extension notification"} +
+ ))} +
+ )} + + {view === "settings" ? ( +
+

Agents and session

+
+ setFolderPath(event.currentTarget.value)} + placeholder="/absolute/path/to/project" + /> + +
+
+

Remembered folders

+ {rememberedPaths().length ? ( + rememberedPaths().map((path) => ( + + )) + ) : ( +

Home only

+ )} +
+

+ {state.sessionName ?? state.sessionId ?? "Session"} ·{" "} + {state.messageCount ?? 0} messages ·{" "} + {state.pendingMessageCount ?? 0} queued +

+
+
+
+

Sessions

+

+ {selected?.worktreePath ?? + "Open a directory to view its sessions"} +

+
+ +
+ {sessions.length ? ( +
+ {sessions.map((session) => ( + + ))} +
+ ) : ( +

No saved sessions for this directory.

+ )} +
+
+ + +
+
+ ) : ( + <> +
+
+
+ {messages.length ? ( + messages.map((entry, index) => ( +
+ {entry.role ?? "message"} +
{messageText(entry)}
+
+ )) + ) : ( +

No messages yet.

+ )} + {pendingSubmissions.map((pending) => ( +
+ + You{" "} + + {pending.phase === "sending" + ? "Sending…" + : "Sent · waiting for Pi"} + + +
{pending.text}
+
+ ))} +
+ +
+
+
+ + +
+
+ {filteredCommands.length > 0 && ( +
+ {filteredCommands.map((command) => ( + + ))} +
+ )} + {commandFollowUp && ( +
+
+ /{commandFollowUp} + +
+ {commandFollowUp === "model" ? ( +
+ {models.map((model) => ( + + ))} +
+ ) : commandFollowUp === "thinking" ? ( +
+ {thinkingLevels.map((level) => ( + + ))} +
+ ) : ( +
+ + {sessions.map((session) => ( + + ))} +
+ )} +
+ )} + {Object.entries(extensionWidgets) + .filter(([, widget]) => widget.placement === "aboveEditor") + .map(([key, widget]) => ( +
+ {widget.lines.map((line, index) => ( +

{line}

+ ))} +
+ ))} +
{ + event.preventDefault(); + void submit(); + }} + > + +