Adds a transcript surface for a Structured-view session alongside the existing xterm attach view: a WS /api/structured/:id connection (structured.ts) mounted by ConnectionPool whenever a session's `view` is `"Structured"`, and pure per-row/rail renderers (structuredRender.ts, structuredDiff.ts) covering all eight transcript row kinds. Hierarchy follows the design reference (artifact 88ecd690): the approval is the only lifted, card-like element; a user turn is a tinted block with an accent rail; agent prose is plain text; tool calls are compact bordered rows expanding to output or a coloured, horizontally-scrolling diff computed from ACP's full old/new text. The client renders `reduced_state` and rows exactly as received, with no raw-event folding or turn tracking of its own. Extracted attach.ts's pane-header title building into paneTitle.ts and its connection interface into pane.ts (PaneConnection) so both connection kinds share one contract and one header.
576 lines
21 KiB
TypeScript
576 lines
21 KiB
TypeScript
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. `Error` is only set
|
|
* when an agent reports failure (#105); the daemon never infers it. */
|
|
export type ActivityState = "Active" | "Idle" | "AwaitingInput" | "Error";
|
|
|
|
export interface Session {
|
|
id: Uuid;
|
|
cwd: string;
|
|
command: string;
|
|
env: Record<string, string>;
|
|
metadata: Record<string, unknown>;
|
|
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;
|
|
/** Short human string an agent reported alongside its state (#105) — e.g.
|
|
* why it is in `Error`. Shown on a sidebar card's meta line while the
|
|
* report is in force; cleared when the report lapses or is replaced. */
|
|
activity_detail?: string | null;
|
|
/** Wall-clock of the most recent observed PTY output (NAP-340). */
|
|
last_activity_at?: IsoTimestamp;
|
|
/** Wall-clock of when the session entered its current `activity` state
|
|
* (#102) — distinct from `last_activity_at`, which only tracks PTY output.
|
|
* Defaults to the Unix epoch on payloads from daemons that predate the
|
|
* field. */
|
|
activity_since?: 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;
|
|
/** The worktree the daemon created for this session, absent when it did not
|
|
* create one. `base_repo` is the repository's main checkout, which is what
|
|
* the session tree groups by (#99). */
|
|
worktree?: SessionWorktree | null;
|
|
/** Wall-clock of when this session last became the pane a client was showing
|
|
* (#145) — stamped by both `POST /api/sessions/:id/focus` and opening
|
|
* `/api/attach/:id`. Absent until the session has been focused once, which is
|
|
* how the command bar's empty query sorts a never-focused session after every
|
|
* session that has one (`docs/COMMAND-BAR.md`). */
|
|
last_focused_at?: IsoTimestamp | null;
|
|
/** The ACP session id the `claude-code-acp` adapter returned from
|
|
* `session/new` (#134). Only a `Structured` session whose handshake
|
|
* succeeded has one; a respawn passes it to `session/load` (#135). */
|
|
acp_session_id?: string | null;
|
|
/** Terminal (PTY bytes) or structured (ACP rows) (#133). Fixed at spawn and
|
|
* immutable for the session's life. Defaults to `"Terminal"` on the wire, so
|
|
* absent means terminal. */
|
|
view?: SessionView;
|
|
}
|
|
|
|
/** A session's process shape (#133): mirrors `maestro_protocol::SessionView`. */
|
|
export type SessionView = "Terminal" | "Structured";
|
|
|
|
/** A session's daemon-created worktree (`SessionWorktree` on the wire). */
|
|
export interface SessionWorktree {
|
|
path: string;
|
|
base_repo: string;
|
|
}
|
|
|
|
/** 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[];
|
|
/** Which commit the daemon was built from. Absent on older daemons. */
|
|
build?: BuildInfo;
|
|
/** Identity of the daemon *process*, regenerated on every start — a changed
|
|
* value across a reconnect means the daemon restarted. Absent on older
|
|
* daemons, which the restart flow treats as "cannot tell". */
|
|
instance?: string;
|
|
/** Canonical leader key the operator configured (`[tui].leader`, default
|
|
* `ctrl-a`), so the web binds the same chord the TUI does (#159). Absent or
|
|
* empty on an older daemon; the client then keeps its own default. */
|
|
leader?: string;
|
|
}
|
|
|
|
/** Build provenance of the running daemon: what the status indicator names in
|
|
* its tooltip, and what tells an upgrade apart from a plain restart. Every
|
|
* field is empty when the binary was built outside a git checkout. */
|
|
export interface BuildInfo {
|
|
/** Abbreviated commit hash. */
|
|
commit: string;
|
|
/** Commit date, RFC 3339. */
|
|
commit_date: string;
|
|
/** Whether the tree had uncommitted changes at build time. */
|
|
dirty: boolean;
|
|
}
|
|
|
|
/** 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[];
|
|
|
|
/** 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;
|
|
/** Lines added across the tracked changes, staged and unstaged together. */
|
|
insertions?: number;
|
|
/** Lines removed across the same tracked changes. */
|
|
deletions?: number;
|
|
}
|
|
|
|
/** One fragment of the sidebar's compact blocked-work stat (docs/SIDEBAR.md).
|
|
* `kind` picks the colour; `text` is what the client prints. */
|
|
export interface BlockerStat {
|
|
kind: "add" | "del" | "files" | "untracked" | "unpushed";
|
|
text: string;
|
|
}
|
|
|
|
/** The blocked-work breakdown as diff notation: `+124 -18 ?3 ↑2`.
|
|
*
|
|
* The line counts cover tracked changes only, so a tree whose only tracked
|
|
* change carries no line delta — a mode change, a rename — falls back to the
|
|
* file count (`~2`) rather than claiming `+0 -0`. Both clients render exactly
|
|
* these fragments; the spelled-out {@link formatInTheWay} survives in the
|
|
* tooltip and in the kill refusal. */
|
|
export function blockerStats(w: InTheWay): BlockerStat[] {
|
|
const stats: BlockerStat[] = [];
|
|
const added = w.insertions ?? 0;
|
|
const removed = w.deletions ?? 0;
|
|
const changed = w.uncommitted_files ?? 0;
|
|
if (added > 0 || removed > 0) {
|
|
if (added > 0) stats.push({ kind: "add", text: `+${added}` });
|
|
if (removed > 0) stats.push({ kind: "del", text: `-${removed}` });
|
|
} else if (changed > 0) {
|
|
stats.push({ kind: "files", text: `~${changed}` });
|
|
}
|
|
if ((w.untracked_files ?? 0) > 0)
|
|
stats.push({ kind: "untracked", text: `?${w.untracked_files}` });
|
|
if ((w.unpushed_commits ?? 0) > 0)
|
|
stats.push({ kind: "unpushed", text: `\u2191${w.unpushed_commits}` });
|
|
return stats;
|
|
}
|
|
|
|
/** 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";
|
|
}
|
|
|
|
/** #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<string, string>;
|
|
metadata?: Record<string, unknown>;
|
|
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;
|
|
/** Set only by the kill guard (#79): what blocked the unforced kill. */
|
|
unsaved_work?: InTheWay;
|
|
};
|
|
}
|
|
|
|
export function sessionName(s: Session): string {
|
|
return customSessionName(s) ?? s.command;
|
|
}
|
|
|
|
/** The session's own name, without the command fallback (#125): `undefined`
|
|
* for a session that was never given one. */
|
|
export function customSessionName(s: Session): string | undefined {
|
|
const meta = s.metadata as Record<string, unknown>;
|
|
const name = meta?.name;
|
|
if (typeof name === "string" && name.length > 0) return name;
|
|
return undefined;
|
|
}
|
|
|
|
/** Where a session's name came from (#131). Mirrors
|
|
* `maestro_protocol::NameSource`. */
|
|
export type NameSource = "User" | "Generated" | "Haiku";
|
|
|
|
/** Read `metadata.name_source`, leniently: anything unrecognised — including
|
|
* a session named before the field existed — is a settled `User` name, so an
|
|
* existing name is never dimmed or overwritten. */
|
|
export function nameSource(s: Session): NameSource {
|
|
const meta = s.metadata as Record<string, unknown>;
|
|
const source = meta?.name_source;
|
|
if (source === "Generated" || source === "Haiku") return source;
|
|
return "User";
|
|
}
|
|
|
|
/** Whether line 1 renders the name as provisional (#131): a handle the daemon
|
|
* generated at spawn and may still replace with one derived from the first
|
|
* prompt. See docs/SIDEBAR.md. */
|
|
export function isProvisionalName(s: Session): boolean {
|
|
return customSessionName(s) !== undefined && nameSource(s) === "Generated";
|
|
}
|
|
|
|
/** Longest session name the daemon stores (#125), in characters. Mirrors
|
|
* `maestro_protocol::MAX_SESSION_NAME_LEN`. */
|
|
export const MAX_SESSION_NAME_LEN = 64;
|
|
|
|
/** Trim a requested session name and cap it at {@link MAX_SESSION_NAME_LEN}
|
|
* characters. Empty means "clear the name", so the card falls back to the
|
|
* command. Mirrors `maestro_protocol::normalize_session_name` — the daemon
|
|
* applies the same rule, this only keeps the UI honest about what it will
|
|
* get back. Uses the spread so the cap counts code points, not UTF-16 units. */
|
|
export function normalizeSessionName(name: string): string {
|
|
return [...name.trim()].slice(0, MAX_SESSION_NAME_LEN).join("");
|
|
}
|
|
|
|
/** The directory a session lives in: its live `current_cwd` if the daemon has
|
|
* observed one, else the spawn `cwd`. */
|
|
export function sessionCwd(s: Session): string | undefined {
|
|
if (typeof s.current_cwd === "string" && s.current_cwd) return s.current_cwd;
|
|
return s.cwd || undefined;
|
|
}
|
|
|
|
/** Where a session sits in the directory tree. A worktree session belongs to
|
|
* its repository, not to the worktree (#99): otherwise `~/tea/agent-skills`
|
|
* grows a `~/tea/agent-skills/worktrees/largo-clarinet` folder holding one
|
|
* session of the same name, and the repository stops being where you look for
|
|
* the work. `base_repo` is the main checkout — the daemon resolves it. */
|
|
export function sessionTreePath(s: Session): string | undefined {
|
|
const repo = s.worktree?.base_repo;
|
|
if (typeof repo === "string" && repo) return repo;
|
|
return sessionCwd(s);
|
|
}
|
|
|
|
/** 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;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Structured-session projections (#133/#140). Mirror of
|
|
// `maestro_protocol`'s "Structured-session projections" section. `data`,
|
|
// `ReducedState.plan/usage/context/pending_approval` and every `Acp*` type
|
|
// below are the daemon's opaque ACP payloads (`serde_json::Value` on the
|
|
// wire) — shaped to `claude-code-acp`'s ACP v1 schema, not covered by
|
|
// maestro-protocol's own compatibility guarantee. Treat every field as
|
|
// optional and degrade gracefully rather than trust this shape exactly.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** `maestro_protocol::TranscriptRowKind`, snake_case on the wire. */
|
|
export type TranscriptRowKind =
|
|
| "user"
|
|
| "agent_text"
|
|
| "agent_thought"
|
|
| "tool_call"
|
|
| "tool_diff"
|
|
| "approval"
|
|
| "plan"
|
|
| "notice";
|
|
|
|
/** One row of a structured session's transcript. */
|
|
export interface TranscriptRow {
|
|
id: string;
|
|
kind: TranscriptRowKind;
|
|
data: unknown;
|
|
}
|
|
|
|
/** The complete transcript at a durable event-log position (`WS
|
|
* /api/structured/:id`'s first message, and every replay page). */
|
|
export interface TranscriptSnapshot {
|
|
cursor: number;
|
|
rows: TranscriptRow[];
|
|
}
|
|
|
|
/** A change to one transcript row, addressed by `id`. */
|
|
export type TranscriptDelta =
|
|
| { type: "append"; row: TranscriptRow }
|
|
| { type: "patch"; row: TranscriptRow }
|
|
| { type: "remove"; id: string };
|
|
|
|
/** Per-turn indicators derived from the ACP stream — read verbatim, never
|
|
* re-derived client-side (docs/STRUCTURED-VIEW.md). */
|
|
export interface TurnState {
|
|
responding: boolean;
|
|
thinking: boolean;
|
|
active_tool_calls: string[];
|
|
}
|
|
|
|
/** A whole-session view derived from ACP notifications. `plan`/`usage`/
|
|
* `context`/`pending_approval` are opaque ACP JSON — see the module note
|
|
* above. `pending_approval` is `{nonce, request: AcpRequestPermissionRequest}`
|
|
* while a permission request is live, absent otherwise. */
|
|
export interface ReducedState {
|
|
turn: TurnState;
|
|
pending_approval?: { nonce: string; request: AcpRequestPermissionRequest } | null;
|
|
plan?: AcpPlan | null;
|
|
usage?: AcpUsage | null;
|
|
context?: AcpUsage | null;
|
|
model?: string | null;
|
|
mode?: string | null;
|
|
}
|
|
|
|
/** Server messages on `WS /api/structured/:id`. */
|
|
export type StructuredEvent =
|
|
| { type: "transcript_snapshot"; snapshot: TranscriptSnapshot }
|
|
| { type: "reduced_state"; state: ReducedState }
|
|
| { type: "projection_update"; transcript?: TranscriptDelta | null; state: ReducedState };
|
|
|
|
/** `POST /api/sessions/:id/structured/prompt` body. */
|
|
export interface StructuredPromptRequest {
|
|
prompt: string;
|
|
}
|
|
|
|
/** `POST /api/sessions/:id/structured/approvals/:nonce` body — a bare JSON
|
|
* string (a bracketless Rust enum), not `{decision: ...}`. */
|
|
export type StructuredApprovalDecision = "allow_once" | "allow_for_session" | "deny";
|
|
|
|
// ---- ACP v1 payload shapes (agent-client-protocol schema, camelCase) ----
|
|
|
|
/** `ContentBlock`. Only `text` is common in practice; the rest degrade to a
|
|
* generic placeholder rather than being dropped silently. */
|
|
export type AcpContentBlock =
|
|
| { type: "text"; text: string }
|
|
| { type: "image"; mimeType?: string }
|
|
| { type: "audio"; mimeType?: string }
|
|
| { type: "resource_link"; uri?: string; name?: string }
|
|
| { type: "resource"; resource?: unknown };
|
|
|
|
/** `ContentChunk` — the payload of a `User`/`AgentText`/`AgentThought` row. */
|
|
export interface AcpContentChunk {
|
|
content: AcpContentBlock;
|
|
messageId?: string | null;
|
|
}
|
|
|
|
/** `ToolKind`, snake_case on the wire. */
|
|
export type AcpToolKind =
|
|
| "read"
|
|
| "edit"
|
|
| "delete"
|
|
| "move"
|
|
| "search"
|
|
| "execute"
|
|
| "think"
|
|
| "fetch"
|
|
| "switch_mode"
|
|
| "other";
|
|
|
|
/** `ToolCallStatus`, snake_case on the wire. */
|
|
export type AcpToolCallStatus = "pending" | "in_progress" | "completed" | "failed";
|
|
|
|
/** `Diff` — a file modification. `oldText` absent means a new file. */
|
|
export interface AcpDiff {
|
|
path: string;
|
|
oldText?: string | null;
|
|
newText: string;
|
|
}
|
|
|
|
/** `ToolCallContent`. */
|
|
export type AcpToolCallContent =
|
|
| { type: "content"; content: AcpContentBlock }
|
|
| ({ type: "diff" } & AcpDiff)
|
|
| { type: "terminal"; terminalId?: string };
|
|
|
|
export interface AcpToolCallLocation {
|
|
path: string;
|
|
line?: number | null;
|
|
}
|
|
|
|
/** `ToolCall` (a `ToolCall`/`ToolDiff` row's `data`) and, loosely, the
|
|
* flattened `ToolCallUpdate` an approval's `toolCall` field carries — the
|
|
* two share every field below, just with different subsets required. */
|
|
export interface AcpToolCallLike {
|
|
toolCallId?: string;
|
|
title?: string;
|
|
/** Unstable ACP capability; absent when the adapter doesn't send one. */
|
|
name?: string | null;
|
|
kind?: AcpToolKind;
|
|
status?: AcpToolCallStatus;
|
|
content?: AcpToolCallContent[];
|
|
locations?: AcpToolCallLocation[];
|
|
rawInput?: unknown;
|
|
rawOutput?: unknown;
|
|
}
|
|
|
|
/** `PlanEntryPriority` / `PlanEntryStatus`, snake_case on the wire. */
|
|
export type AcpPlanPriority = "high" | "medium" | "low";
|
|
export type AcpPlanEntryStatus = "pending" | "in_progress" | "completed";
|
|
|
|
export interface AcpPlanEntry {
|
|
content: string;
|
|
priority: AcpPlanPriority;
|
|
status: AcpPlanEntryStatus;
|
|
}
|
|
|
|
/** `Plan` — `ReducedState.plan`. */
|
|
export interface AcpPlan {
|
|
entries: AcpPlanEntry[];
|
|
}
|
|
|
|
/** `UsageUpdate` — `ReducedState.usage`/`.context` (the daemon folds one
|
|
* update into both fields). */
|
|
export interface AcpUsage {
|
|
used: number;
|
|
size: number;
|
|
cost?: { amount: number; currency: string } | null;
|
|
}
|
|
|
|
export interface AcpPermissionOption {
|
|
optionId: string;
|
|
name: string;
|
|
kind: "allow_once" | "allow_always" | "reject_once" | "reject_always";
|
|
}
|
|
|
|
/** `RequestPermissionRequest`, serialized without its nonce (the nonce lives
|
|
* only in `ReducedState.pending_approval.nonce`) — an `Approval` row's `data`,
|
|
* and `ReducedState.pending_approval.request`. */
|
|
export interface AcpRequestPermissionRequest {
|
|
sessionId: string;
|
|
toolCall: AcpToolCallLike;
|
|
options: AcpPermissionOption[];
|
|
}
|
|
|
|
/** `~/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 `<span class="folder-stem">${escapeHtml(stem)}/</span>${escapeHtml(last)}`;
|
|
}
|