import { escapeHtml } from "./html.js"; // Mirror of `maestro-protocol` JSON shapes. Keep in sync with // crates/maestro-protocol/src/lib.rs. export type Uuid = string; export type IsoTimestamp = string; export type SessionStatus = "Running" | "Dead"; /** Daemon-inferred activity dimension, orthogonal to `SessionStatus` (NAP-340). * Absent on payloads from daemons that predate the field. */ export type ActivityState = "Active" | "Idle" | "AwaitingInput"; export interface Session { id: Uuid; cwd: string; command: string; env: Record; metadata: Record; status: SessionStatus; created_at: IsoTimestamp; /** Current foreground command in the pane (e.g. `claude`, `bash`), as the * daemon's activity watcher observed it. Absent until observed (NAP-340). */ foreground?: string | null; /** Inferred activity. Defaults to `Idle` on the wire when omitted. */ activity?: ActivityState; /** Wall-clock of the most recent observed PTY output (NAP-340). */ last_activity_at?: IsoTimestamp; /** The pane's *current* working directory, as the daemon's activity watcher * observed it (`#{pane_current_path}`) — distinct from `cwd` (the spawn dir). * Drives the directory-based session tree. Absent until first observed. */ current_cwd?: string | null; /** What currently blocks an unforced kill of this session (#83), as the * daemon's watcher last probed it. Absent until probed and for sessions * without a worktree; all counts default to 0 on the wire for older * daemons. */ blocked_work?: InTheWay | null; } /** One directory's immediate subdirectories (NAP-329). */ export interface DirListing { /** Resolved absolute directory that was listed (after ~/relative expansion). */ path: string; /** Immediate subdirectory names, sorted case-insensitively. */ entries: string[]; /** True when the listing was capped server-side. */ truncated?: boolean; } /** A spawn-form command preset advertised by the daemon (#22/#26). The `custom` * preset has an empty `command` and reveals the free-text command input. */ export interface SpawnPreset { id: string; label: string; command: string; } /** `GET /api/info` — the daemon's identity plus its spawn presets (#26). * Mirrors the daemon-side `DaemonInfo`. */ export interface DaemonInfo { hostname: string; version: string; /** Home dir of the user the daemon runs as; used to prefill the spawn form's * working directory. Empty/absent for older daemons. */ home_dir?: string; /** Command presets (claude/opencode/zsh + custom), config-resolved. */ presets: SpawnPreset[]; } /** One deduplicated recent working dir for the spawn form's recents picker * (#24/#26). */ export interface RecentDir { cwd: string; last_command: string; last_used: IsoTimestamp; } /** `GET /api/recent-dirs` — the daemon's recent working dirs (#26). */ export interface RecentDirs { dirs: RecentDir[]; truncated?: boolean; } /** `GET /api/gitroot?path=` — the enclosing repo toplevel of a path, or absent * when the path isn't inside a repo (#26). */ export interface GitRoot { root?: string; } /** `GET /api/names?count=N` — a batch of fresh session handles (#53). Mirrors * `maestro_protocol::NameBatch`. */ export interface NameBatch { names: string[]; } /** One discovered git repository (or linked worktree) for `GET /api/projects` * (#54). Mirrors `maestro_protocol::Project`. */ export interface Project { /** Absolute path of the repository/worktree toplevel. */ path: string; /** Basename of `path`. */ name: string; is_worktree?: boolean; /** The main checkout this worktree belongs to; absent on plain repos. */ base_repo?: string; /** Later of the repo's git HEAD mtime and the newest spawn-history use of * any directory inside it; absent when neither is known. */ last_activity?: IsoTimestamp; /** Command last spawned in this project, from spawn history. */ last_command?: string; } /** `GET /api/projects` — every discovered project, sorted by the daemon * newest-activity-first (#54). */ export type Projects = Project[]; export interface WorktreeSpec { base_repo: string; branch: string; /** When false, `branch` already exists and is checked out as-is (#61). * Defaults to true (create a new branch) on older daemons. */ create_branch?: boolean; } /** One local branch of the enclosing repository, from `GET /api/branches` * (#55). Mirrors `maestro_protocol::BranchInfo`. */ export interface BranchInfo { /** Short branch name (`main`, `feature/foo`). */ name: string; /** Whether this branch is the main checkout's current HEAD. */ is_head: boolean; /** Toplevel path of the linked worktree holding this branch, if any. */ worktree?: string | null; } /** `GET /api/branches?path=` — the local branches of one repository (#55). */ export type Branches = BranchInfo[]; /** Why a retained worktree outlived its session (#80). New values are * additive; unknown reasons render generically. */ export type RetainedReason = "force-killed" | "exited-with-unsaved-work"; /** What currently blocks an unforced kill of a worktree's session (#80), * recomputed by the daemon on every read. All counts default to 0 on the * wire for older daemons. */ export interface InTheWay { uncommitted_files?: number; untracked_files?: number; unpushed_commits?: number; } /** One entry of `GET /api/worktrees` (#80): a retained worktree with its * in-the-way state as of this read. Reclaiming it removes the directory but * never the branch. Mirrors `maestro_protocol::RetainedWorktree`. */ export interface RetainedWorktree { id: Uuid; path: string; base_repo: string; branch: string; reason: RetainedReason; retained_at: IsoTimestamp; in_the_way?: InTheWay; } /** The phrasing `maestro_protocol::InTheWay`'s Display impl uses — shared by * the kill refusal, the TUI and this client, so no two clients can describe * the same tree differently. Zero parts are omitted; all-zero renders * "no unsaved work". */ export function formatInTheWay(w: InTheWay): string { const parts: string[] = []; const push = (n: number | undefined, unit: string): void => { if (!n || n <= 0) return; parts.push(n === 1 ? `1 ${unit}` : `${n} ${unit}s`); }; push(w.uncommitted_files, "uncommitted file"); push(w.untracked_files, "untracked file"); push(w.unpushed_commits, "unpushed commit"); return parts.length > 0 ? parts.join(", ") : "no unsaved work"; } /** Same reason labels the CLI table and the TUI overlay use; unknown future * reasons render their raw wire value. */ export function reasonLabel(reason: RetainedReason): string { return reason; } /** #83: this session's blocked-work breakdown when something actually blocks * an unforced kill; `null` while clear or not yet probed. Render it with * {@link formatInTheWay} so every client phrases it identically. */ export function sessionBlockers(s: Session): InTheWay | null { const w = s.blocked_work; if (!w) return null; const blocked = (w.uncommitted_files ?? 0) > 0 || (w.untracked_files ?? 0) > 0 || (w.unpushed_commits ?? 0) > 0; return blocked ? w : null; } export interface CreateSessionRequest { cwd: string; command: string; env?: Record; metadata?: Record; worktree?: WorktreeSpec; } export type CatalogEvent = | { type: "Snapshot"; sessions: Session[] } | { type: "SessionAdded"; session: Session } | { type: "SessionRemoved"; id: Uuid } | { type: "SessionUpdated"; session: Session } | { type: "Lagged"; skipped: number }; export type ClientControl = | { type: "Resize"; cols: number; rows: number } | { type: "Ping" } | { type: "Pong" } | { type: "Error"; code: string; message: string }; export interface ApiError { error: { code: string; message: string }; } export function sessionName(s: Session): string { const meta = s.metadata as Record; const name = meta?.name; if (typeof name === "string" && name.length > 0) return name; return s.command; } /** The directory a session lives in: its live `current_cwd` if the daemon has * observed one, else the spawn `cwd`. Drives the directory-based session tree. */ export function sessionCwd(s: Session): string | undefined { if (typeof s.current_cwd === "string" && s.current_cwd) return s.current_cwd; return s.cwd || undefined; } /** Collapse a daemon's home dir to `~`. `home` comes from `DaemonInfo.home_dir`; * absent/empty leaves the path untouched. Mirrors the CLI's `abbreviate_dir`. */ export function abbreviateCwd(cwd: string, home?: string): string { if (home && (cwd === home || cwd.startsWith(`${home}/`))) { return `~${cwd.slice(home.length)}`; } return cwd; } /** `~/git.naps.pt/naps62/maestro` → `~/g/n/maestro`: keep the last segment * verbatim, collapse every earlier one to its first character. Two-or-fewer * segments are left alone (nothing to gain). Returns escaped HTML with the * collapsed stem wrapped in `.folder-stem` so callers' CSS can dim it. Shared * by the sidebar tree and the spawn dialog's recent-directory picker so both * abbreviate paths identically. */ export function abbreviateFolderName(name: string): string { const parts = name.split("/"); if (parts.length <= 2) return escapeHtml(name); const last = parts[parts.length - 1]; const stem = parts .slice(0, -1) .map((p) => (p === "~" ? "~" : Array.from(p)[0] ?? "")) .join("/"); return `${escapeHtml(stem)}/${escapeHtml(last)}`; }