577 lines
21 KiB
TypeScript
577 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;
|
|
view?: SessionView;
|
|
}
|
|
|
|
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)}`;
|
|
}
|