| 1 | // Typed wrappers around Tauri's `invoke` and `listen`. The frontend |
| 2 | // should import from here, never from `@tauri-apps/api/core` directly — |
| 3 | // that keeps the IPC surface area in one place and typed against the |
| 4 | // ts-rs generated contracts. |
| 5 | |
| 6 | import { invoke } from "@tauri-apps/api/core"; |
| 7 | import { listen, type UnlistenFn } from "@tauri-apps/api/event"; |
| 8 | import type { |
| 9 | Message, |
| 10 | PermissionMode, |
| 11 | Project, |
| 12 | SessionDetail, |
| 13 | SessionSource, |
| 14 | SessionSummary, |
| 15 | } from "./types"; |
| 16 | |
| 17 | export async function listProjects(): Promise<Project[]> { |
| 18 | return invoke<Project[]>("list_projects"); |
| 19 | } |
| 20 | |
| 21 | /** @deprecated Projects returned by `listProjects` now carry their |
| 22 | * sessions embedded. Only kept for debugging / future callers. */ |
| 23 | export async function listSessions( |
| 24 | encodedDir: string, |
| 25 | ): Promise<SessionSummary[]> { |
| 26 | return invoke<SessionSummary[]>("list_sessions", { encodedDir }); |
| 27 | } |
| 28 | |
| 29 | /** Read the full viewer payload for a session. |
| 30 | * |
| 31 | * For disk sessions: `encodedDir` is the session's own `projectId` |
| 32 | * field (the physical source dir), **not** the merged logical |
| 33 | * project id from `Project.id`. |
| 34 | * |
| 35 | * For archive sessions (reconstructed from `~/.claude/history.jsonl`): |
| 36 | * `encodedDir` is the absolute project path (still `session.projectId`, |
| 37 | * since archive sessions use the project path there too). The |
| 38 | * `source` tag drives the dispatch on the Rust side. */ |
| 39 | export async function readSession( |
| 40 | encodedDir: string, |
| 41 | sessionId: string, |
| 42 | source: SessionSource = "disk", |
| 43 | ): Promise<SessionDetail> { |
| 44 | return invoke<SessionDetail>("read_session", { |
| 45 | encodedDir, |
| 46 | sessionId, |
| 47 | source, |
| 48 | }); |
| 49 | } |
| 50 | |
| 51 | export async function rescan(): Promise<Project[]> { |
| 52 | return invoke<Project[]>("rescan"); |
| 53 | } |
| 54 | |
| 55 | /** Sent by the Rust watcher whenever a session file is added, |
| 56 | * modified, or removed under `~/.claude/projects/`. */ |
| 57 | export type SessionChangeEvent = |
| 58 | | { kind: "added"; path: string } |
| 59 | | { kind: "modified"; path: string } |
| 60 | | { kind: "removed"; path: string }; |
| 61 | |
| 62 | /** Subscribe to live session changes. Returns an unlisten function. */ |
| 63 | export async function onSessionsChanged( |
| 64 | cb: (ev: SessionChangeEvent) => void, |
| 65 | ): Promise<UnlistenFn> { |
| 66 | return listen<SessionChangeEvent>("sessions:changed", (e) => cb(e.payload)); |
| 67 | } |
| 68 | |
| 69 | // ============================================================================ |
| 70 | // Chat turn commands + event bus |
| 71 | // ============================================================================ |
| 72 | |
| 73 | /** Mirrors the Rust `ChatEventWire` enum in commands.rs. One tagged |
| 74 | * payload per `chat:event` emission. */ |
| 75 | export type ChatEvent = |
| 76 | | { |
| 77 | kind: "turn_started"; |
| 78 | turnId: string; |
| 79 | resumeSessionId: string | null; |
| 80 | newSessionId: string | null; |
| 81 | } |
| 82 | | { kind: "session_bound"; turnId: string; sessionId: string } |
| 83 | | { kind: "message"; turnId: string; message: Message } |
| 84 | | { kind: "stderr"; turnId: string; line: string } |
| 85 | | { kind: "turn_completed"; turnId: string; exitCode: number } |
| 86 | | { kind: "turn_failed"; turnId: string; reason: string } |
| 87 | | { kind: "turn_cancelled"; turnId: string }; |
| 88 | |
| 89 | export interface StartTurnArgs { |
| 90 | /** Client-generated uuid — the frontend owns turn identity so it |
| 91 | * has something to pass to cancelTurn before start_turn returns. */ |
| 92 | turnId: string; |
| 93 | /** Absolute working directory. Must exist on disk. */ |
| 94 | cwd: string; |
| 95 | /** If set, resumes an existing session by id. Mutually exclusive |
| 96 | * with `newSessionId`. */ |
| 97 | resumeSessionId: string | null; |
| 98 | /** If set, starts a new session with the given client-generated |
| 99 | * uuid via `--session-id`. Mutually exclusive with |
| 100 | * `resumeSessionId`. */ |
| 101 | newSessionId: string | null; |
| 102 | prompt: string; |
| 103 | permissionMode?: PermissionMode; |
| 104 | } |
| 105 | |
| 106 | export async function startTurn(args: StartTurnArgs): Promise<void> { |
| 107 | return invoke("start_turn", { |
| 108 | turnId: args.turnId, |
| 109 | cwd: args.cwd, |
| 110 | resumeSessionId: args.resumeSessionId, |
| 111 | newSessionId: args.newSessionId, |
| 112 | prompt: args.prompt, |
| 113 | permissionMode: args.permissionMode ?? "auto", |
| 114 | }); |
| 115 | } |
| 116 | |
| 117 | export async function cancelTurn(turnId: string): Promise<void> { |
| 118 | return invoke("cancel_turn", { turnId }); |
| 119 | } |
| 120 | |
| 121 | export async function listActiveTurns(): Promise<string[]> { |
| 122 | return invoke<string[]>("list_active_turns"); |
| 123 | } |
| 124 | |
| 125 | /** Subscribe to `chat:event` payloads emitted while chat turns are |
| 126 | * in flight. Returns an unlisten function. */ |
| 127 | export async function onChatEvent( |
| 128 | cb: (ev: ChatEvent) => void, |
| 129 | ): Promise<UnlistenFn> { |
| 130 | return listen<ChatEvent>("chat:event", (e) => cb(e.payload)); |
| 131 | } |
| 132 | |
| 133 | // ============================================================================ |
| 134 | // PTY commands + event bus |
| 135 | // ============================================================================ |
| 136 | |
| 137 | /** One live PTY as reported by `list_ptys`. Mirrors the Rust |
| 138 | * `PtyInfo` struct in commands.rs. Used by the titlebar popover |
| 139 | * and on mount to reconcile the store's `ptyIds` map. */ |
| 140 | export interface PtyInfo { |
| 141 | ptyId: string; |
| 142 | sessionId: string | null; |
| 143 | cwd: string; |
| 144 | startedAt: string; |
| 145 | } |
| 146 | |
| 147 | /** Stdout chunk from a live PTY, base64-encoded. Decode with |
| 148 | * `atob` + `Uint8Array.from(..., c => c.charCodeAt(0))` before |
| 149 | * passing to `xterm.write`. */ |
| 150 | export interface PtyDataEvent { |
| 151 | ptyId: string; |
| 152 | base64: string; |
| 153 | } |
| 154 | |
| 155 | /** Fired exactly once when a PTY subprocess exits. `exitCode` is |
| 156 | * `null` when the subprocess was killed by a signal (no clean |
| 157 | * status code available). */ |
| 158 | export interface PtyExitEvent { |
| 159 | ptyId: string; |
| 160 | exitCode: number | null; |
| 161 | } |
| 162 | |
| 163 | export interface SpawnPtyArgs { |
| 164 | /** Client-generated uuid — the frontend owns pty identity so the |
| 165 | * TerminalPane can pass a stable id to every subsequent call. */ |
| 166 | ptyId: string; |
| 167 | /** Claudex session this PTY belongs to. `null` for detached |
| 168 | * terminals (not currently exposed in the UI). */ |
| 169 | sessionId: string | null; |
| 170 | /** Absolute working directory. Must exist on disk. */ |
| 171 | cwd: string; |
| 172 | /** Argv for the claude subprocess. Usually |
| 173 | * `["--resume", <id>]` or `["--session-id", <uuid>]`. */ |
| 174 | args: string[]; |
| 175 | cols: number; |
| 176 | rows: number; |
| 177 | } |
| 178 | |
| 179 | export async function spawnPty(args: SpawnPtyArgs): Promise<void> { |
| 180 | return invoke("spawn_pty", { |
| 181 | ptyId: args.ptyId, |
| 182 | sessionId: args.sessionId, |
| 183 | cwd: args.cwd, |
| 184 | args: args.args, |
| 185 | cols: args.cols, |
| 186 | rows: args.rows, |
| 187 | }); |
| 188 | } |
| 189 | |
| 190 | /** Send a keystroke / paste string into the PTY master. `data` is |
| 191 | * a utf-8 string; the backend passes the bytes verbatim so escape |
| 192 | * sequences round-trip correctly. */ |
| 193 | export async function writePty(ptyId: string, data: string): Promise<void> { |
| 194 | return invoke("write_pty", { ptyId, data }); |
| 195 | } |
| 196 | |
| 197 | /** Propagate an xterm resize down to the PTY master. Triggers |
| 198 | * `SIGWINCH` in the subprocess on unix. */ |
| 199 | export async function resizePty( |
| 200 | ptyId: string, |
| 201 | cols: number, |
| 202 | rows: number, |
| 203 | ): Promise<void> { |
| 204 | return invoke("resize_pty", { ptyId, cols, rows }); |
| 205 | } |
| 206 | |
| 207 | /** Explicit teardown — kill the subprocess and remove its entry |
| 208 | * from the backend's `active_ptys` map. No-op if the pty id is |
| 209 | * unknown. */ |
| 210 | export async function closePty(ptyId: string): Promise<void> { |
| 211 | return invoke("close_pty", { ptyId }); |
| 212 | } |
| 213 | |
| 214 | /** Snapshot the ring buffer and return it as base64. Called on |
| 215 | * reattach to replay recent stdout into a freshly-mounted xterm. */ |
| 216 | export async function getPtyBuffer(ptyId: string): Promise<string> { |
| 217 | return invoke<string>("get_pty_buffer", { ptyId }); |
| 218 | } |
| 219 | |
| 220 | /** List every live PTY with enough metadata to render the |
| 221 | * titlebar popover and reconcile the store's `ptyIds` map. */ |
| 222 | export async function listPtys(): Promise<PtyInfo[]> { |
| 223 | return invoke<PtyInfo[]>("list_ptys"); |
| 224 | } |
| 225 | |
| 226 | /** Subscribe to `pty:data` payloads. The frontend filters by its |
| 227 | * own `ptyId` inside the callback — one event channel is shared |
| 228 | * across every live PTY. Returns an unlisten function. */ |
| 229 | export async function onPtyData( |
| 230 | cb: (ev: PtyDataEvent) => void, |
| 231 | ): Promise<UnlistenFn> { |
| 232 | return listen<PtyDataEvent>("pty:data", (e) => cb(e.payload)); |
| 233 | } |
| 234 | |
| 235 | /** Subscribe to `pty:exit` payloads. Fires once per PTY when its |
| 236 | * subprocess exits (naturally, killed via `close_pty`, or on |
| 237 | * window-destroy shutdown). Returns an unlisten function. */ |
| 238 | export async function onPtyExit( |
| 239 | cb: (ev: PtyExitEvent) => void, |
| 240 | ): Promise<UnlistenFn> { |
| 241 | return listen<PtyExitEvent>("pty:exit", (e) => cb(e.payload)); |
| 242 | } |
| 243 | |
| 244 | // ============================================================================ |
| 245 | // Frontend log bridge |
| 246 | // ============================================================================ |
| 247 | |
| 248 | export type LogLevel = "error" | "warn" | "info" | "debug"; |
| 249 | |
| 250 | /** Forward a structured log entry into Rust tracing, which writes |
| 251 | * it to the daily-rotated file at |
| 252 | * `~/Library/Logs/claudex/claudex.log.<date>`. Used by the global |
| 253 | * error handlers in `src/lib/debug.ts` so a silent React crash |
| 254 | * still leaves a trace on disk. */ |
| 255 | export async function logFrontend( |
| 256 | level: LogLevel, |
| 257 | source: string, |
| 258 | message: string, |
| 259 | stack?: string, |
| 260 | ): Promise<void> { |
| 261 | try { |
| 262 | await invoke("log_frontend", { level, source, message, stack }); |
| 263 | } catch { |
| 264 | // Swallow — logging must never raise its own errors. |
| 265 | } |
| 266 | } |