// The command bar's pure model (docs/COMMAND-BAR.md), lifted from // ../../commandBar.ts for the React port (#247): what a candidate is, the // order an empty query lists them in, how a query line splits into a sigil and // a query, and how a ranked list groups for rendering. // // Nothing here touches the DOM or the network apart from `loadBarDirectories`, // which is the one place the `~` list comes from. import { listDir, projects as fetchProjects, recentDirs } from "../../api.js"; import type { Command } from "../../commands.js"; import { splitPath } from "../../paths.js"; import { splitCommand } from "../../toolIcon.js"; import { abbreviateCwd, sessionName, sessionTreePath, type Session } from "../../types.js"; import { matchAndRank, type CandidateKind, type CommandCandidate, type DirectoryCandidate, type MatchRange, type MatchResult, type SessionCandidate, } from "../../utils/matcher.js"; /** A matcher candidate that remembers what it came from, so a result can be * acted on without a second lookup. The extra property is invisible to the * matcher, which only ever reads `kind` and `fields`. */ export type SessionBarCandidate = SessionCandidate & { session: Session }; export type CommandBarCandidate = CommandCandidate & { command: Command }; /** `fields.path` is the abbreviated path the operator reads and the matcher * ranks; `path` is the one a spawn is made in. */ export type DirectoryBarCandidate = DirectoryCandidate & { path: string }; /** One row of a session's action page. It is a `command` to the matcher — a * single `label` field, ranked exactly like a registry command — and carries * the action it stands for instead of a registry entry. */ export type ActionBarCandidate = CommandCandidate & { action: SessionAction }; export type BarCandidate = | SessionBarCandidate | CommandBarCandidate | ActionBarCandidate | DirectoryBarCandidate; export type BarResult = MatchResult; /** One kind's slice of the ranked list, in rank order. */ export interface BarGroup { kind: CandidateKind; title: string; results: MatchResult[]; } const KIND_TITLES: Record = { session: "Sessions", command: "Commands", directory: "Directories", }; // ---- the sigils ---- export type BarSigil = ">" | "~"; export interface BarQuery { /** The sigil in force, or null for the mixed list. */ sigil: BarSigil | null; /** What the matcher sees: everything after a sigil, or the whole query. */ query: string; } export const PLACEHOLDERS: Record<"mixed" | BarSigil, string> = { mixed: "Go to a session…", ">": "Run a command…", "~": "Spawn in a directory…", }; /** Split a raw query line into its sigil and the query proper. * * A sigil is only a sigil in first position: `>` or `~` anywhere else is a * literal query character, which is what a directory query with a second `~` * in it depends on. Backspacing past a sigil needs no special case either — * the first character goes and the query is a mixed one again. */ export function parseBarQuery(raw: string): BarQuery { const first = raw[0]; if (first === ">" || first === "~") return { sigil: first, query: raw.slice(1) }; return { sigil: null, query: raw }; } // ---- the session action page ---- /** The actions a session's action page offers, and no others: the bar picks * one and hands off to the flow that already implements it * (docs/COMMAND-BAR.md, "Drill-in"). */ export type SessionActionId = "kill" | "rename" | "spawn-similar"; export interface SessionAction { id: SessionActionId; label: string; } export const SESSION_ACTIONS: readonly SessionAction[] = [ { id: "kill", label: "Kill session" }, { id: "rename", label: "Rename session" }, { id: "spawn-similar", label: "Spawn similar" }, ]; /** The action page's candidates, in the order above — which is the order an * empty query lists them in, since the matcher keeps input order. */ export function sessionActionCandidates(): ActionBarCandidate[] { return SESSION_ACTIONS.map((action) => ({ kind: "command", action, fields: { label: action.label }, })); } /** Narrow a `command` candidate to an action row. Both share the matcher's * `command` kind, so what separates them is what they carry. */ export function isActionCandidate(candidate: BarCandidate): candidate is ActionBarCandidate { return candidate.kind === "command" && "action" in candidate; } // ---- candidates ---- function focusedAt(s: Session): number | null { const stamp = s.last_focused_at; if (typeof stamp !== "string" || stamp === "") return null; const ms = Date.parse(stamp); return Number.isNaN(ms) ? null : ms; } function createdAt(s: Session): number { const ms = Date.parse(s.created_at ?? ""); return Number.isNaN(ms) ? 0 : ms; } /** The empty-query order (docs/COMMAND-BAR.md, "Empty query"): most recently * focused first; a session that has never been focused sorts after every * session that has, most recently created first among those; and a dead * session sorts after every live one regardless of recency, because a session * you cannot attach to is not where you just were. * * This is also the input order for a non-empty query, which is what the * matcher's last tie-break preserves. */ export function orderSessionsForBar(sessions: readonly Session[]): Session[] { return [...sessions].sort((a, b) => { const deadA = a.status === "Dead" ? 1 : 0; const deadB = b.status === "Dead" ? 1 : 0; if (deadA !== deadB) return deadA - deadB; const focusA = focusedAt(a); const focusB = focusedAt(b); if ((focusA === null) !== (focusB === null)) return focusA === null ? 1 : -1; if (focusA !== null && focusB !== null && focusA !== focusB) return focusB - focusA; return createdAt(b) - createdAt(a); }); } /** Build one session's candidate: the fields docs/COMMAND-BAR.md's candidate * table names, with the directory abbreviated the way the sidebar shows it. */ export function sessionCandidate(s: Session, homeDir?: string): SessionBarCandidate { const treePath = sessionTreePath(s); const directory = treePath ? abbreviateCwd(treePath, homeDir) : undefined; const tool = splitCommand(s.command).tool ?? undefined; return { kind: "session", session: s, fields: { name: sessionName(s), ...(directory ? { directory } : {}), ...(s.command ? { command: s.command } : {}), ...(tool ? { tool } : {}), }, }; } /** Every session as a candidate, in the empty-query order. */ export function sessionBarCandidates( sessions: readonly Session[], homeDir?: string, ): SessionBarCandidate[] { return orderSessionsForBar(sessions).map((s) => sessionCandidate(s, homeDir)); } /** Drop the commands that would list twice. Several chords can be bound to one * action — the registry already carries its aliases on one entry, but nothing * stops two entries running the same handler, and listed naively that is two * identical rows. The dedupe is by **action**, never by chord: the chord is * displayed, so a command with two of them is still one row. */ export function dedupeCommands(commands: readonly Command[]): Command[] { const seenRun = new Set(); const seenLabel = new Set(); const kept: Command[] = []; for (const command of commands) { if (seenRun.has(command.run) || seenLabel.has(command.label)) continue; seenRun.add(command.run); seenLabel.add(command.label); kept.push(command); } return kept; } /** Every bound command as a candidate, in registry order. Only the label is a * field: the chord is displayed beside the row, never matched, because * matching it would make `>` results depend on a user's rebinds. */ export function commandBarCandidates(commands: readonly Command[]): CommandBarCandidate[] { return dedupeCommands(commands).map((command) => ({ kind: "command", command, fields: { label: command.label }, })); } /** `~/dev/api` back to `/home/me/dev/api` — the inverse of `abbreviateCwd`, for * a path the operator typed or `/api/dirs` completed. */ export function expandHome(path: string, homeDir?: string): string { if (!homeDir) return path; if (path === "~") return homeDir; return path.startsWith("~/") ? `${homeDir}${path.slice(1)}` : path; } export function directoryCandidate(path: string, homeDir?: string): DirectoryBarCandidate { const absolute = expandHome(path, homeDir); return { kind: "directory", path: absolute, fields: { path: abbreviateCwd(absolute, homeDir) } }; } /** Candidates for `~`, deduped by the directory they spawn in and keeping the * first mention of each — recent dirs (newest first) ahead of the project * list, which is the empty-query order the contract asks for. */ export function directoryBarCandidates( paths: readonly string[], homeDir?: string, ): DirectoryBarCandidate[] { const seen = new Set(); const out: DirectoryBarCandidate[] = []; for (const path of paths) { if (path === "") continue; const candidate = directoryCandidate(path, homeDir); if (seen.has(candidate.path)) continue; seen.add(candidate.path); out.push(candidate); } return out; } /** The directory list the bar starts from: what the spawn form already knows — * `GET /api/recent-dirs` and the project list — either alone if the other * fails. */ export async function loadBarDirectories(): Promise { const [recents, discovered] = await Promise.all([ recentDirs() .then((r) => r.dirs.map((d) => d.cwd)) .catch(() => [] as string[]), fetchProjects() .then((list) => list.map((p) => p.path)) .catch(() => [] as string[]), ]); return [...recents, ...discovered]; } /** One directory's children as candidate paths, for filling the `~` list in as * a path is typed. The directory being listed comes from the query itself. */ export function dirEntryPaths(dir: string, entries: readonly string[]): string[] { return entries.map((entry) => `${dir}${entry}`); } /** The directory a `~` query names, or `""` when it names none yet. */ export function probeDir(query: string): string { return splitPath(query).dir; } /** `GET /api/dirs`, in the shape the bar wants it. */ export function listDirEntries(dir: string): Promise { return listDir(dir).then((listing) => listing.entries); } // ---- grouping ---- /** Bucket a ranked list by kind for rendering. Grouping is a rendering of the * ranking, never a reordering of it: a group holds its matches in rank order, * and groups appear in the order their best-scoring member does — so the first * row under the first header is the globally best result. */ export function barGroups( results: readonly MatchResult[], ): BarGroup[] { const groups: BarGroup[] = []; for (const result of results) { const kind = result.candidate.kind; let group = groups.find((g) => g.kind === kind); if (!group) { group = { kind, title: KIND_TITLES[kind], results: [] }; groups.push(group); } group.results.push(result); } return groups; } /** The rows a query produces, flat and in rank order — what the highlight walks * and what Enter acts on. Headers are labels, not rows, so they are never in * this list. */ export function barRows( query: string, candidates: readonly C[], ): MatchResult[] { return matchAndRank(query, candidates); } // ---- row text ---- /** The name line of a row: the session's name, the command's label, or the * directory's path. */ export function rowName(candidate: BarCandidate): string { switch (candidate.kind) { case "session": return candidate.fields.name; case "command": return candidate.fields.label; case "directory": return candidate.fields.path; } } /** The secondary line under a session row: the field the match was found in * when that was not the name, so a session found through its directory says * so. Only sessions have one — a command shows keycaps instead, and a * directory's path is already the name. */ export function metaText(result: BarResult): string | undefined { if (result.candidate.kind !== "session") return undefined; const fields = result.candidate.fields; switch (result.field) { case "directory": return fields.directory; case "command": return fields.command; case "tool": return fields.tool; default: return fields.directory; } } /** One run of a row's text, flagged when the matcher matched inside it. The * React rendering of `renderHighlighted`: ranges come from the matcher as * `[start, end)` character pairs over the field that won. */ export interface HighlightSegment { text: string; hit: boolean; } export function highlightSegments( text: string, ranges: readonly MatchRange[], ): HighlightSegment[] { const out: HighlightSegment[] = []; let cursor = 0; for (const [start, end] of ranges) { if (start > cursor) out.push({ text: text.slice(cursor, start), hit: false }); out.push({ text: text.slice(start, end), hit: true }); cursor = end; } if (cursor < text.length) out.push({ text: text.slice(cursor), hit: false }); return out; } export { sessionName }; export type { CandidateKind, MatchRange, MatchResult };