diff --git a/.gitignore b/.gitignore index f77683a..eb00ccf 100644 --- a/.gitignore +++ b/.gitignore @@ -2,9 +2,12 @@ node_modules/ .tmp/ web/.pi-web-state.json web/dist/ +# Managed pi worktrees are ephemeral checkouts, not repo content. +.pi/worktrees/ .staffreview/diffs/ .staffreview/attachments/ .staffreview/active.json .staffreview/section-cache.json .DS_Store *.log +.claude/worktrees diff --git a/README.md b/README.md index fac55fd..1587a48 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ The subagent extension independently contributes its token use and status to `ex `extensions/auto-router.ts` adds an "Auto" entry to `/model`. Selecting it routes each turn to a model/reasoning-effort pair chosen from your own configured lists, based on the turn's classified complexity, and fails over to other configured models or tiers when one is unhealthy or out of usage. -Configure it under a new `autoRouter` key in `~/.pi/agent/settings.json` (or `.pi/settings.json` for a project override): +Configure it under a new `autoRouter` key in `~/.pi/agent/settings.json`: ```json { diff --git a/extensions/auto-router-health.ts b/extensions/auto-router-health.ts index 5dd8d2d..bac54ac 100644 --- a/extensions/auto-router-health.ts +++ b/extensions/auto-router-health.ts @@ -89,7 +89,11 @@ export function parseRetryAfterMs( headers: Record | undefined, now: number, ): number | undefined { - const raw = headers?.["retry-after"] ?? headers?.["Retry-After"]; + const raw = headers + ? Object.entries(headers).find( + ([name]) => name.toLowerCase() === "retry-after", + )?.[1] + : undefined; if (!raw) return undefined; const seconds = Number(raw); if (Number.isFinite(seconds) && seconds >= 0) return seconds * 1000; @@ -375,7 +379,9 @@ export class AutoRouterHealthStore { if (this.writeTimer) return; this.writeTimer = setTimeout(() => { this.writeTimer = undefined; - void this.flush(); + // Best-effort telemetry: a transient write failure (ENOSPC, EACCES, ...) must not become + // an unhandled rejection with no caller to catch it, which would crash the process. + void this.flush().catch(() => undefined); }, SAVE_DEBOUNCE_MS); this.writeTimer.unref?.(); } diff --git a/extensions/auto-router.ts b/extensions/auto-router.ts index c1e25db..5cf4b4c 100644 --- a/extensions/auto-router.ts +++ b/extensions/auto-router.ts @@ -506,9 +506,28 @@ export default async function autoRouter(pi: ExtensionAPI): Promise { // that's *configured* somewhere in autoRouter (picked manually from /model, or left over from // before Auto was engaged) is just as real a signal for future routing decisions and /usage, // so it's tracked the same way regardless of who selected the model. + const TRACKED_SETTINGS_CACHE_MS = 5_000; + let trackedSettingsCache: { settings: AutoRouterSettings; expiresAt: number } | undefined; + + // Short-TTL cache scoped to this membership check specifically: after_provider_response and + // message_end can both fire multiple times per turn, and re-reading + re-parsing settings.json + // from disk for each one is wasted work when nothing's changed. Routing decisions themselves + // (routeForPrompt, /usage, reconciliation) still always read fresh, since staleness there would + // mean routing on config the user no longer has - a few seconds of staleness in "is this model + // even one we track" is a much cheaper trade. + async function trackedSettings(): Promise { + const now = Date.now(); + if (trackedSettingsCache && trackedSettingsCache.expiresAt > now) { + return trackedSettingsCache.settings; + } + const settings = await readAutoRouterSettings(); + trackedSettingsCache = { settings, expiresAt: now + TRACKED_SETTINGS_CACHE_MS }; + return settings; + } + async function trackedModel(model: ModelIdentity | undefined): Promise { if (!model || model.provider === AUTO_PROVIDER_ID) return undefined; - const settings = await readAutoRouterSettings(); + const settings = await trackedSettings(); const configured = allConfiguredModels(settings).some( (candidate) => candidate.provider === model.provider && candidate.id === model.id, ); @@ -543,7 +562,8 @@ export default async function autoRouter(pi: ExtensionAPI): Promise { }); pi.on("session_shutdown", () => { - void healthStore.flush(); + // Best-effort telemetry: a transient write failure must not become an unhandled rejection. + void healthStore.flush().catch(() => undefined); currentSessionId = undefined; autoActive = false; }); diff --git a/extensions/subagents/manager.ts b/extensions/subagents/manager.ts index 8383b8f..eac8d44 100644 --- a/extensions/subagents/manager.ts +++ b/extensions/subagents/manager.ts @@ -72,6 +72,7 @@ import { USAGE_STATE_ENTRY, type Usage, WEB_STATUS_PUBLISH_INTERVAL_MS, + DEFAULT_READ_WAIT_SECONDS, } from "./types.js"; import { addUsage, @@ -373,9 +374,13 @@ export class SubagentManager { agent.activity.splice(0, removed); agent.lastReadActivity = Math.max(0, agent.lastReadActivity - removed); } + this.publishFooter(); + } + + private wakeReadWaiters(agent: ManagedSubagent): void { + if (agent.waiters.size === 0) return; for (const waiter of agent.waiters) waiter(); agent.waiters.clear(); - this.publishFooter(); } private addTranscript(agent: ManagedSubagent, message: unknown): void { @@ -481,6 +486,12 @@ export class SubagentManager { ); break; case "agent_end": + // Don't wake waiters here: `willRetry: false` only means this particular run won't + // auto-retry - Pi can still continue with queued follow-ups before ever reaching a + // real terminal state, so waking now can hand back a still-"working" snapshot and + // force the caller to wait a full cycle again for the actual completion. The terminal + // transition (agent_settled below, or attachRun's own handlers) wakes waiters once the + // status has actually changed. if (event.willRetry) this.activity(agent, "waiting to retry"); break; case "agent_settled": @@ -494,6 +505,7 @@ export class SubagentManager { ? `failed${agent.error ? `: ${agent.error}` : ` (${agent.lastStopReason})`}` : "completed and is waiting for more instructions", ); + this.wakeReadWaiters(agent); } break; case "auto_retry_start": @@ -531,6 +543,7 @@ export class SubagentManager { agent.status = "completed"; agent.completedAt = Date.now(); this.activity(agent, "task run settled"); + this.wakeReadWaiters(agent); } }) .catch((error: unknown) => { @@ -540,6 +553,7 @@ export class SubagentManager { agent.error = error instanceof Error ? error.message : String(error); agent.completedAt = Date.now(); this.activity(agent, `failed: ${agent.error}`); + this.wakeReadWaiters(agent); }); } @@ -650,6 +664,7 @@ export class SubagentManager { agent.error = error instanceof Error ? error.message : String(error); agent.completedAt = Date.now(); this.activity(agent, `${agent.status}: ${agent.error}`); + this.wakeReadWaiters(agent); throw error; } } @@ -768,6 +783,7 @@ export class SubagentManager { agent.status = "terminated"; agent.completedAt = Date.now(); this.activity(agent, "terminated and released session resources"); + this.wakeReadWaiters(agent); } this.agents.delete(id); this.webTranscriptCursors.delete(id); @@ -807,21 +823,22 @@ export class SubagentManager { return terminalAgents.length; } - private hasUnread(agent: ManagedSubagent): boolean { - return agent.lastReadActivity < agent.activity.length; - } - async waitForUpdates( agents: ManagedSubagent[], seconds: number, signal?: AbortSignal, ): Promise { - if (seconds <= 0 || agents.some((agent) => this.hasUnread(agent))) return; + // No early-return on "any unread activity": `agent.activity` still grows on every routine + // event (tool start/end, throttled streaming text, queue updates), so that check would fire + // for almost any actively-working agent between two reads and skip the wait entirely, + // defeating the whole point of it. An already-terminal agent is instead caught below by + // `running.length === 0`, which is the actual "nothing worth waiting for" case. const running = agents.filter( (agent) => agent.status === "creating" || agent.status === "working", ); if (running.length === 0) return; + const waitSeconds = Math.max(seconds, DEFAULT_READ_WAIT_SECONDS); await new Promise((done) => { let finished = false; const finish = () => { @@ -832,44 +849,46 @@ export class SubagentManager { signal?.removeEventListener("abort", finish); done(); }; - const timer = setTimeout(finish, Math.min(30, seconds) * 1_000); + const timer = setTimeout(finish, waitSeconds * 1_000); for (const agent of running) agent.waiters.add(finish); signal?.addEventListener("abort", finish, { once: true }); }); } - read(agents: ManagedSubagent[], includeTranscript: boolean): string { + private readSummary(agent: ManagedSubagent): string { + const now = Date.now(); + const metadata = [ + `Model: ${agent.model}`, + `Effort: ${agent.effort}`, + `Elapsed: ${formatDuration((agent.completedAt ?? now) - agent.createdAt)}`, + `Turns: ${agent.turns}`, + `Usage: ↑${formatTokens(agent.usage.input)} ↓${formatTokens(agent.usage.output)}${agent.usage.cost.total ? ` $${agent.usage.cost.total.toFixed(4)}` : ""}`, + ]; + if (agent.currentTool) metadata.push(`Current tool: ${agent.currentTool}`); + if (agent.queuedSteering || agent.queuedFollowUp) { + metadata.push( + `Queued: ${agent.queuedSteering} steering, ${agent.queuedFollowUp} follow-up`, + ); + } + if (agent.error) metadata.push(`Error: ${agent.error}`); + + if (!isTerminalSubagentStatus(agent.status)) { + return `${metadata.join("\n")}\n\nAwaiting completion before returning assistant output.`; + } + + const latest = finalAssistantText(agent); + if (!latest) return `${metadata.join("\n")}\n\nCompletion summary: (no assistant output)`; + return `${metadata.join("\n")}\n\nCompletion summary:\n${truncateChars(latest, 3_000)}`; + } + + async read(agents: ManagedSubagent[], includeTranscript: boolean): Promise { if (agents.length === 0) return "No subagents are involved in this session."; - const now = Date.now(); const sections: string[] = []; + const terminalAgents: string[] = []; for (const agent of agents) { const heading = `## ${statusIcon(agent.status)} ${agent.id} — ${agent.status}`; - const metadata = [ - `Model: ${agent.model}`, - `Effort: ${agent.effort}`, - `Elapsed: ${formatDuration((agent.completedAt ?? now) - agent.createdAt)}`, - `Turns: ${agent.turns}`, - `Usage: ↑${formatTokens(agent.usage.input)} ↓${formatTokens(agent.usage.output)}${agent.usage.cost.total ? ` $${agent.usage.cost.total.toFixed(4)}` : ""}`, - ]; - if (agent.currentTool) - metadata.push(`Current tool: ${agent.currentTool}`); - if (agent.queuedSteering || agent.queuedFollowUp) { - metadata.push( - `Queued: ${agent.queuedSteering} steering, ${agent.queuedFollowUp} follow-up`, - ); - } - if (agent.error) metadata.push(`Error: ${agent.error}`); - - const unread = agent.activity.slice(agent.lastReadActivity); - const activity = unread.length - ? unread - .map((item) => `- ${formatClock(item.timestamp)} ${item.text}`) - .join("\n") - : "- No new activity."; - agent.lastReadActivity = agent.activity.length; - - let output = `${heading}\n${metadata.join("\n")}\n\nActivity since last read:\n${activity}`; + let output = `${heading}\n${this.readSummary(agent)}`; if (includeTranscript) { const transcript = agent.transcript .map( @@ -878,14 +897,18 @@ export class SubagentManager { ) .join("\n\n"); output += `\n\nTranscript:\n${transcript || agent.streamingText || "(empty)"}`; - } else { - const latest = finalAssistantText(agent); - if (latest) output += `\n\nLatest assistant output:\n${latest}`; } sections.push(output); - if (this.archivedAgents.get(agent.id) === agent) - this.archivedAgents.delete(agent.id); + agent.lastReadActivity = agent.activity.length; + if (isTerminalSubagentStatus(agent.status)) terminalAgents.push(agent.id); } + + // Removing an archived agent from `archivedAgents` here (before terminate() runs) would + // make it unresolvable by id - `terminate(id, true)` looks the agent up via `getAgent` + // first and only then removes it, so let it own that removal instead. + if (terminalAgents.length > 0) + await Promise.all(terminalAgents.map((id) => this.terminate(id, true))); + return truncateToolOutput(sections.join("\n\n---\n\n")); } diff --git a/extensions/subagents/tools.ts b/extensions/subagents/tools.ts index 5f2523c..fd651ff 100644 --- a/extensions/subagents/tools.ts +++ b/extensions/subagents/tools.ts @@ -74,8 +74,8 @@ const ReadParams = Type.Object({ ), wait_seconds: Type.Optional( Type.Integer({ - description: `Wait for meaningful new activity before returning. Default ${DEFAULT_READ_WAIT_SECONDS}, maximum 30.`, - minimum: 0, + description: `Wait for meaningful subagent state changes before returning. Default and minimum ${DEFAULT_READ_WAIT_SECONDS}.`, + minimum: DEFAULT_READ_WAIT_SECONDS, maximum: 30, }), ), @@ -134,7 +134,7 @@ export function registerSubagentTools( "Create a background subagent with a chosen prompt, model, and effort", promptGuidelines: [ "When calling subagent_create, omit model to inherit the current model unless deliberately choosing one of the exact session-available provider/model IDs listed in the system prompt; never shorten or invent a model ID.", - "After subagent_create returns, use subagent_read with its default wait roughly every 15–30 seconds while work continues; briefly tell the user about meaningful progress between polls without narrating every event.", + "After subagent_create returns, use subagent_read with wait_seconds 30 while work continues; expect a completion summary when the task transitions to completed. Re-poll only at that cadence for stalled work.", "Wait for subagent_create to return before calling another subagent management tool for that id.", "Use subagent_send with urgent only when the current approach must change immediately; use normal for work that can wait until the current run finishes.", "Use subagent_terminate when delegated work is no longer needed, and clean up retained subagents before finishing when appropriate.", @@ -174,7 +174,7 @@ export function registerSubagentTools( name: "subagent_read", label: "Read subagents", description: - "Wait for and read meaningful subagent activity, status, output, usage, or full transcripts. Omit id to monitor all subagents.", + "Wait for meaningful subagent state updates. Completed subagents return a concise summary and are auto-released after read. Omit id to monitor all subagents.", promptSnippet: "Read and monitor background subagent activity and output", parameters: ReadParams, async execute(_toolCallId, params, signal) { @@ -187,7 +187,7 @@ export function registerSubagentTools( if (signal?.aborted) throw new Error("Subagent read was cancelled"); return toolResult( manager, - manager.read(agents, params.include_transcript ?? false), + await manager.read(agents, params.include_transcript ?? false), ); }, renderCall(args, theme) { diff --git a/extensions/subagents/types.ts b/extensions/subagents/types.ts index 9f1d0de..ba58545 100644 --- a/extensions/subagents/types.ts +++ b/extensions/subagents/types.ts @@ -14,7 +14,7 @@ export const MAX_WEB_TRANSCRIPT_CHARS = 100_000; export const MAX_WEB_STREAMING_CHARS = 20_000; export const WEB_STATUS_PUBLISH_INTERVAL_MS = 1_000; export const MAX_TOOL_OUTPUT_BYTES = 50 * 1024; -export const DEFAULT_READ_WAIT_SECONDS = 15; +export const DEFAULT_READ_WAIT_SECONDS = 30; export const DETAIL_VIEW_LINES = 22; export const USAGE_STATE_ENTRY = "vessup-subagent-usage"; export const SUBAGENT_SYSTEM_PROMPT = [ diff --git a/extensions/web-sessions.ts b/extensions/web-sessions.ts index 6d8c576..68c33e6 100644 --- a/extensions/web-sessions.ts +++ b/extensions/web-sessions.ts @@ -1058,6 +1058,13 @@ async function connect(pi: ExtensionAPI, state: BridgeState): Promise { entries: boundedWebHistory( state.ctx.sessionManager.buildContextEntries(), ), + // Forward the session's --models scope so the daemon's model picker + // shows the same list the TUI would. + scopedModels: state.ctx.scopedModels.map((item) => ({ + provider: item.model.provider, + id: item.model.id, + thinkingLevel: item.thinkingLevel, + })), }; socket.send(JSON.stringify(hello)); if (state.sourceReplacement) { diff --git a/tests/auto-router-health.test.ts b/tests/auto-router-health.test.ts index 1c38f42..d019694 100644 --- a/tests/auto-router-health.test.ts +++ b/tests/auto-router-health.test.ts @@ -51,6 +51,11 @@ test("parseRetryAfterMs reads an HTTP-date header", () => { expect(parseRetryAfterMs({ "retry-after": future }, NOW)).toBeCloseTo(60_000, -2); }); +test("parseRetryAfterMs finds the header regardless of casing", () => { + expect(parseRetryAfterMs({ "RETRY-AFTER": "30" }, NOW)).toBe(30_000); + expect(parseRetryAfterMs({ "Retry-After": "30" }, NOW)).toBe(30_000); +}); + test("parseRetryAfterMs returns undefined when the header is missing or unparseable", () => { expect(parseRetryAfterMs(undefined, NOW)).toBeUndefined(); expect(parseRetryAfterMs({}, NOW)).toBeUndefined(); diff --git a/tests/subagents.test.ts b/tests/subagents.test.ts index 0905318..01590d2 100644 --- a/tests/subagents.test.ts +++ b/tests/subagents.test.ts @@ -4,6 +4,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import test from "node:test"; import { ModelRegistry, ModelRuntime } from "@earendil-works/pi-coding-agent"; +import { SubagentManager } from "../extensions/subagents/manager.ts"; import { stringifyCompact, truncateChars, @@ -16,19 +17,17 @@ import { } from "../extensions/subagents/ui.ts"; import subagentsExtension, { abortRunningSubagentSessions, - appendBoundedStreamingText, countsAgainstSubagentLimit, filterModelsToScope, inheritedSubagentModel, isFailedStopReason, isTerminalSubagentStatus, - MAX_WEB_STREAMING_CHARS, parsePersistedUsageState, shouldArchiveTerminalSubagent, subagentModelGuidance, subagentModelRuntime, } from "../extensions/subagents.ts"; - +import type { ManagedSubagent } from "../extensions/subagents/types.ts"; test("subagent entrypoint preserves its tool, command, and lifecycle registrations", () => { const tools: string[] = []; const commands: string[] = []; @@ -91,6 +90,39 @@ const usage = { }, }; +function makeManagedAgent( + override: Partial = {}, +): ManagedSubagent { + const now = Date.now(); + return { + id: "worker", + prompt: "task", + cwd: "/tmp", + createdAt: now - 1_000, + updatedAt: now, + status: "completed", + model: "provider/model", + effort: "medium", + turns: 1, + queuedSteering: 0, + queuedFollowUp: 0, + activity: [{ timestamp: now - 10, text: "assistant finished" }], + lastReadActivity: 0, + transcript: [ + { + timestamp: now - 5, + role: "assistant", + text: "Subagent summary of work completed.", + }, + ], + streamingText: "", + lastStreamActivityAt: 0, + usage: usage, + waiters: new Set(), + ...override, + }; +} + test("compact formatting handles non-JSON values and preserves Unicode code points", () => { assert.equal(stringifyCompact(undefined), "undefined"); assert.equal(stringifyCompact(Symbol("value")), "Symbol(value)"); @@ -355,13 +387,52 @@ test("subagent model guidance exposes exact choices and inheritance", () => { assert.match(guidance, /Never shorten, generalize, or invent a model ID/); }); -test("streaming subagent output remains bounded to its newest text", () => { - const prefix = "a".repeat(MAX_WEB_STREAMING_CHARS - 2); - assert.equal(appendBoundedStreamingText(prefix, "bc"), `${prefix}bc`); - assert.equal( - appendBoundedStreamingText(prefix, "012345"), - `${prefix.slice(4)}012345`, +test("subagent read returns a concise completion summary and auto-releases terminal agents", async () => { + const manager = new SubagentManager({ + events: { emit() {} }, + } as never); + const agent = makeManagedAgent(); + + (manager as { agents: Map }).agents.set( + agent.id, + agent, ); + + const output = await manager.read([agent], false); + + assert.ok(output.includes("Completion summary:")); + assert.equal(output.includes("Activity since last read:"), false); + assert.equal(manager.list().length, 0); +}); + +test("subagent read includes transcript only when requested", async () => { + const manager = new SubagentManager({ + events: { emit() {} }, + } as never); + const withTranscript = makeManagedAgent({ id: "detailed" }); + + (manager as { agents: Map }).agents.set( + withTranscript.id, + withTranscript, + ); + + const without = await manager.read([withTranscript], false); + assert.equal(without.includes("Transcript:"), false); + + // Re-insert a completed agent for the detailed-read assertion. + (manager as { agents: Map }).agents.set( + withTranscript.id, + { + ...withTranscript, + lastReadActivity: 0, + status: "completed", + waiters: new Set(), + }, + ); + + const withTranscriptOutput = await manager.read([withTranscript], true); + assert.ok(withTranscriptOutput.includes("Transcript:")); + assert.ok(withTranscriptOutput.includes(withTranscript.transcript[0]?.text)); }); test("persisted usage checkpoints reject malformed data", () => { diff --git a/tests/web-suggestions.test.ts b/tests/web-suggestions.test.ts new file mode 100644 index 0000000..7fed855 --- /dev/null +++ b/tests/web-suggestions.test.ts @@ -0,0 +1,201 @@ +import { afterEach, expect, test } from "bun:test"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { ServerStateFile } from "../web/protocol.ts"; +import { listDirectorySuggestions } from "../web/server/suggestions.ts"; +import { listRepositoryBranches } from "../web/server/worktrees.ts"; + +let child: Bun.Subprocess | undefined; +let tempDir: string | undefined; + +afterEach(async () => { + if (child) { + child.kill("SIGTERM"); + await child.exited.catch(() => undefined); + child = undefined; + } + if (tempDir) { + await rm(tempDir, { recursive: true, force: true }); + tempDir = undefined; + } +}); + +test("directory suggestions list home directories in ~ shorthand", async () => { + tempDir = await mkdtemp(join(tmpdir(), "pi-kit-suggestions-")); + const home = join(tempDir, "home"); + for (const directory of ["alpha", "beta", "vessup", ".cache"]) { + await mkdir(join(home, directory), { recursive: true }); + } + await writeFile(join(home, "notes.txt"), "not a directory\n"); + + const visible = listDirectorySuggestions("", { homeDir: home }); + expect(visible).toEqual(["~/alpha", "~/beta", "~/vessup"]); + + expect(listDirectorySuggestions("~", { homeDir: home })).toEqual(visible); + expect(listDirectorySuggestions("~/", { homeDir: home })).toEqual(visible); + + expect(listDirectorySuggestions("~/ve", { homeDir: home })).toEqual([ + "~/vessup", + ]); + // Without a trailing slash the last segment stays a prefix filter, so a + // complete directory name suggests itself rather than its children. + expect(listDirectorySuggestions("~/vessup", { homeDir: home })).toEqual([ + "~/vessup", + ]); + expect(listDirectorySuggestions("~/vessup/", { homeDir: home })).toEqual([]); + expect(listDirectorySuggestions("~/miss", { homeDir: home })).toEqual([]); + + // Hidden directories only appear when the prefix itself is hidden. + expect(listDirectorySuggestions("~/.ca", { homeDir: home })).toEqual([ + "~/.cache", + ]); +}); + +test("directory suggestions stop inside a Git repository", async () => { + tempDir = await mkdtemp(join(tmpdir(), "pi-kit-suggestions-repo-")); + const home = join(tempDir, "home"); + await mkdir(join(home, "plain"), { recursive: true }); + const repository = join(home, "project"); + await Bun.$`git init -q -b main ${repository}`; + + // The repository itself still completes from its parent directory... + expect(listDirectorySuggestions("~/pro", { homeDir: home })).toEqual([ + "~/project", + ]); + // ...but nothing beneath it is suggested. + expect(listDirectorySuggestions("~/project/", { homeDir: home })).toEqual([]); + expect(listDirectorySuggestions("~/project/s", { homeDir: home })).toEqual( + [], + ); + expect(listDirectorySuggestions("~/plain", { homeDir: home })).toEqual([ + "~/plain", + ]); +}); + +test("directory suggestions keep absolute form outside home", async () => { + tempDir = await mkdtemp(join(tmpdir(), "pi-kit-suggestions-")); + const home = join(tempDir, "home"); + const elsewhere = join(tempDir, "elsewhere"); + await mkdir(join(home, "inside"), { recursive: true }); + await mkdir(join(elsewhere, "project"), { recursive: true }); + + expect( + listDirectorySuggestions(`${elsewhere}/pro`, { + baseDir: tempDir, + homeDir: home, + }), + ).toEqual([join(elsewhere, "project")]); + expect( + listDirectorySuggestions(`${elsewhere}/`, { + baseDir: tempDir, + homeDir: home, + }), + ).toEqual([join(elsewhere, "project")]); +}); + +test("repository branches list local and remote refs", async () => { + tempDir = await mkdtemp(join(tmpdir(), "pi-kit-branches-")); + const repository = join(tempDir, "project"); + await Bun.$`git init -q -b main ${repository}`; + await Bun.$`git -C ${repository} config user.name test`; + await Bun.$`git -C ${repository} config user.email test@example.com`; + await Bun.write(join(repository, "README.md"), "test\n"); + await Bun.$`git -C ${repository} add README.md`; + await Bun.$`git -C ${repository} commit -qm initial`; + await Bun.$`git -C ${repository} branch owner/topic`; + await Bun.$`git -C ${repository} update-ref refs/remotes/origin/feature-x refs/heads/main`; + await Bun.$`git -C ${repository} symbolic-ref refs/remotes/origin/HEAD refs/remotes/origin/main`; + + const branches = listRepositoryBranches(repository); + expect(branches.local).toEqual(["main", "owner/topic"]); + expect(branches.remote).toEqual(["origin/feature-x"]); +}); + +test("branch listing fails outside a Git repository", async () => { + tempDir = await mkdtemp(join(tmpdir(), "pi-kit-branches-")); + expect(() => listRepositoryBranches(tempDir)).toThrow(); +}); + +test("web server serves directory and branch suggestions", async () => { + tempDir = await mkdtemp(join(tmpdir(), "pi-kit-suggestions-http-")); + const home = join(tempDir, "home"); + await mkdir(join(home, "vessup"), { recursive: true }); + const repository = join(tempDir, "project"); + await Bun.$`git init -q -b main ${repository}`; + await Bun.$`git -C ${repository} config user.name test`; + await Bun.$`git -C ${repository} config user.email test@example.com`; + await Bun.write(join(repository, "README.md"), "test\n"); + await Bun.$`git -C ${repository} add README.md`; + await Bun.$`git -C ${repository} commit -qm initial`; + await Bun.$`git -C ${repository} branch feature`; + + const statePath = join(tempDir, "server.json"); + child = Bun.spawn({ + cmd: ["bun", "run", "web/server/index.ts"], + cwd: process.cwd(), + env: { + ...process.env, + HOME: home, + PI_WEB_PORT: "0", + PI_WEB_ROOT: process.cwd(), + PI_WEB_STATE_FILE: statePath, + PI_CODING_AGENT_DIR: join(tempDir, "pi-agent"), + }, + stdout: "ignore", + stderr: "ignore", + }); + const deadline = Date.now() + 8_000; + let state: ServerStateFile | undefined; + while (Date.now() < deadline) { + try { + state = JSON.parse(await Bun.file(statePath).text()) as ServerStateFile; + break; + } catch { + await Bun.sleep(50); + } + } + if (!state) throw new Error("web server state file was not created"); + + const directoryResponse = await fetch( + `http://127.0.0.1:${state.port}/api/directories?q=${encodeURIComponent(`${home}/`)}`, + ); + expect(directoryResponse.ok).toBe(true); + const directories = (await directoryResponse.json()) as { + directories: string[]; + }; + // Paths under the server's home render with the ~ shorthand. Server startup + // can create ~/Library inside the fake home, so assert containment. + expect(directories.directories).toContain("~/vessup"); + + const prefixResponse = await fetch( + `http://127.0.0.1:${state.port}/api/directories?q=${encodeURIComponent(`${home}/ves`)}`, + ); + expect( + ((await prefixResponse.json()) as { directories: string[] }).directories, + ).toEqual(["~/vessup"]); + + const branchResponse = await fetch( + `http://127.0.0.1:${state.port}/api/branches?cwd=${encodeURIComponent(repository)}`, + ); + expect(branchResponse.ok).toBe(true); + const branches = (await branchResponse.json()) as { + local: string[]; + remote: string[]; + }; + expect(branches.local).toEqual(["feature", "main"]); + expect(branches.remote).toEqual([]); + + const missingResponse = await fetch( + `http://127.0.0.1:${state.port}/api/branches`, + ); + expect(missingResponse.status).toBe(400); + + const plainDirectoryResponse = await fetch( + `http://127.0.0.1:${state.port}/api/branches?cwd=${encodeURIComponent(home)}`, + ); + expect(plainDirectoryResponse.ok).toBe(true); + expect( + ((await plainDirectoryResponse.json()) as { local: string[] }).local, + ).toEqual([]); +}); diff --git a/web/client/api.ts b/web/client/api.ts index d4180f6..86cd07b 100644 --- a/web/client/api.ts +++ b/web/client/api.ts @@ -134,6 +134,37 @@ export async function listSessions(): Promise { return Array.isArray(data) ? data : data.sessions; } +export type BranchSuggestions = { local: string[]; remote: string[] }; + +/** Autocomplete directories under home; returns [] when the daemon is older or the path is unreadable. */ +export async function listDirectorySuggestions( + query: string, +): Promise { + try { + const data = await fetchJson<{ directories: string[] }>( + `/api/directories?q=${encodeURIComponent(query)}`, + { cache: "no-store" }, + ); + return data.directories; + } catch { + return []; + } +} + +/** Autocomplete local and remote branches for a repository; returns empty lists on failure. */ +export async function listBranchSuggestions( + cwd: string, +): Promise { + try { + return await fetchJson( + `/api/branches?cwd=${encodeURIComponent(cwd)}`, + { cache: "no-store" }, + ); + } catch { + return { local: [], remote: [] }; + } +} + export async function createSession( request: CreateSessionRequest, ): Promise { diff --git a/web/client/app.tsx b/web/client/app.tsx index 1f8d3a3..ecf5062 100644 --- a/web/client/app.tsx +++ b/web/client/app.tsx @@ -58,6 +58,7 @@ import { } from "../protocol"; import { includeWebReloadCommand, isWebReloadCommand } from "../reload-command"; import { + type BranchSuggestions, cloneSessionViaCommand, compactSessionViaCommand, createSession, @@ -65,6 +66,8 @@ import { type ForkMessageItem, forkSessionViaCommand, getForkMessages, + listBranchSuggestions, + listDirectorySuggestions, listSessions, openSessionSocket, renameSessionViaCommand, @@ -90,10 +93,6 @@ import { localCommandEntryId, preserveLocalCommandEntries, } from "./local-command"; -import { - type RecentRepository, - recentRepositories, -} from "./recent-repositories"; import { mergeSemanticHistory, preserveSemanticEntryKeys, @@ -391,79 +390,282 @@ function projectGroups(sessions: WebSession[]): ProjectSessionGroup[] { return Array.from(groups.values()); } +type Suggestion = { value: string; label?: string }; + +function filterSuggestions( + suggestions: readonly Suggestion[], + query: string, +): Suggestion[] { + const trimmed = query.trim().toLowerCase(); + if (!trimmed) return [...suggestions]; + return suggestions.filter( + (suggestion) => + suggestion.value.toLowerCase().includes(trimmed) || + (suggestion.value.split(/[\\/]/).pop() ?? "") + .toLowerCase() + .includes(trimmed), + ); +} + +/** Text input with an anchored autocomplete list driven by caller-provided suggestions. */ +function AutocompleteInput({ + id, + label, + value, + onChange, + suggestions, + placeholder, + hint, + acceptSuffix, +}: { + id: string; + label: string; + value: string; + onChange: (value: string) => void; + suggestions: readonly Suggestion[]; + placeholder?: string; + hint?: string; + /** Appended to accepted values, e.g. "/" for directories so the menu keeps drilling down. */ + acceptSuffix?: string; +}) { + const inputRef = React.useRef(null); + const [open, setOpen] = React.useState(false); + const [activeIndex, setActiveIndex] = React.useState(0); + const filtered = React.useMemo( + () => filterSuggestions(suggestions, value), + [suggestions, value], + ); + React.useEffect(() => { + setActiveIndex((index) => Math.min(index, filtered.length - 1)); + }, [filtered.length]); + const popoverOpen = open && filtered.length > 0; + const accept = (suggestion: Suggestion) => { + const completed = suggestion.value.endsWith(acceptSuffix ?? "") + ? suggestion.value + : suggestion.value + (acceptSuffix ?? ""); + onChange(completed); + // With a completion suffix the next segment's suggestions load next; keep + // the menu up so repeated Tab presses drill down the path. + if (!acceptSuffix) setOpen(false); + inputRef.current?.focus(); + }; + return ( +
+ + { + onChange(event.target.value); + setOpen(true); + setActiveIndex(0); + }} + onFocus={() => setOpen(true)} + onKeyDown={(event) => { + if (!popoverOpen) return; + if (event.key === "ArrowDown") { + event.preventDefault(); + setActiveIndex((index) => Math.min(index + 1, filtered.length - 1)); + } else if (event.key === "ArrowUp") { + event.preventDefault(); + setActiveIndex((index) => Math.max(index - 1, 0)); + } else if (event.key === "Enter" || event.key === "Tab") { + // Tab accepts the highlighted option instead of moving focus; the + // menu closes, so a second Tab advances to the next field as usual. + const suggestion = filtered[activeIndex]; + if (suggestion) { + event.preventDefault(); + accept(suggestion); + } + } else if (event.key === "Escape") { + // Consume Escape while the menu is up so it dismisses the menu + // instead of bubbling to the dialog and closing the whole modal. + event.stopPropagation(); + setOpen(false); + } + }} + /> + +
    + {filtered.map((suggestion, index) => ( +
  • + +
  • + ))} +
+
+ {hint ?

{hint}

: null} +
+ ); +} + +/** Turn a branch name such as owner/topic into one safe worktree path segment. */ +function worktreeNameFromBranch(branch: string): string { + return branch.replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^-+|-+$/g, ""); +} + function NewSessionDialog({ open, - baseSession, - repositories, onOpenChange, onCreate, }: { open: boolean; - baseSession: WebSession | null; - repositories: RecentRepository[]; onOpenChange: (open: boolean) => void; onCreate: (value: CreateSessionRequest) => Promise; }) { const [repository, setRepository] = React.useState(""); - const [name, setName] = React.useState(""); + const [repositorySuggestions, setRepositorySuggestions] = React.useState< + Suggestion[] + >([]); + const [branch, setBranch] = React.useState(""); + const [branchSuggestions, setBranchSuggestions] = + React.useState({ local: [], remote: [] }); const [worktreeName, setWorktreeName] = React.useState(""); - const [worktreeBranch, setWorktreeBranch] = React.useState(""); - const [worktreeStartPoint, setWorktreeStartPoint] = React.useState(""); + const [worktreeNameEdited, setWorktreeNameEdited] = React.useState(false); + const [name, setName] = React.useState(""); + const [nameEdited, setNameEdited] = React.useState(false); const [busy, setBusy] = React.useState(false); const [createError, setCreateError] = React.useState(null); const repositoryListId = React.useId(); React.useEffect(() => { if (!open) return; - setRepository(baseSession?.repositoryRoot ?? baseSession?.cwd ?? ""); - setName(""); + setRepository("~/"); + setRepositorySuggestions([]); + setBranch(""); + setBranchSuggestions({ local: [], remote: [] }); setWorktreeName(""); - setWorktreeBranch(""); - setWorktreeStartPoint(""); + setWorktreeNameEdited(false); + setName(""); + setNameEdited(false); setBusy(false); setCreateError(null); - }, [baseSession?.cwd, baseSession?.repositoryRoot, open]); + }, [open]); + React.useEffect(() => { + if (!open) return; + let cancelled = false; + const handle = window.setTimeout(() => { + void listDirectorySuggestions(repository).then((directories) => { + if (!cancelled) + setRepositorySuggestions( + directories.map((directory) => ({ value: directory })), + ); + }); + }, 150); + return () => { + cancelled = true; + window.clearTimeout(handle); + }; + }, [open, repository]); + const repositoryQuery = repository.trim(); + React.useEffect(() => { + if (!open || !repositoryQuery) return; + let cancelled = false; + const handle = window.setTimeout(() => { + void listBranchSuggestions(repositoryQuery).then((branches) => { + if (!cancelled) setBranchSuggestions(branches); + }); + }, 250); + return () => { + cancelled = true; + window.clearTimeout(handle); + }; + }, [open, repositoryQuery]); + const branchSuggestionsForInput = React.useMemo( + () => [ + ...branchSuggestions.local.map((value) => ({ value, label: "local" })), + ...branchSuggestions.remote.map((value) => ({ value, label: "remote" })), + ], + [branchSuggestions], + ); + const trimmedBranch = branch.trim(); + // A branch that matches a remote-tracking ref selects it: the local branch + // is derived by stripping the remote prefix, and the remote ref becomes the + // start point so the new branch tracks it. + const remoteBranch = branchSuggestions.remote.find( + (candidate) => candidate === trimmedBranch, + ); + const localBranch = remoteBranch + ? remoteBranch.slice(remoteBranch.indexOf("/") + 1) + : trimmedBranch; + const startPoint = + remoteBranch && !branchSuggestions.local.includes(localBranch) + ? remoteBranch + : undefined; + React.useEffect(() => { + if (worktreeNameEdited) return; + setWorktreeName(worktreeNameFromBranch(localBranch)); + }, [localBranch, worktreeNameEdited]); + React.useEffect(() => { + if (nameEdited) return; + setName(worktreeName.trim()); + }, [nameEdited, worktreeName]); return ( New session - Choose a repository or directory. Add a worktree name to create or - reuse a linked checkout. + Choose a repository directory. Pick a branch to open it in a linked + worktree. -
- - { - setRepository(event.target.value); - setCreateError(null); - }} - placeholder="~/path/to/repository" - role="combobox" - aria-autocomplete="list" - /> - - {repositories.map((item) => ( - - ))} - -

- Recently used repositories appear as you type. -

-
+ { + setRepository(value); + setCreateError(null); + }} + suggestions={repositorySuggestions} + placeholder="~/path/to/repository" + acceptSuffix="/" + hint="Suggestions list directories under ~ and stop once a Git repository is selected." + /> + { + setBranch(value); + setCreateError(null); + }} + suggestions={branchSuggestionsForInput} + placeholder="Optional, e.g. main or origin/owner/topic" + hint="Local and remote branches of the repository. Choosing a remote branch creates a local branch that tracks it." + />
- {worktreeName.trim() && ( - <> -
- - { - setWorktreeBranch(event.target.value); - setCreateError(null); - }} - placeholder={`Defaults to ${worktreeName.trim()}`} - /> -
-
- - { - setWorktreeStartPoint(event.target.value); - setCreateError(null); - }} - placeholder="Optional, e.g. origin/owner/topic" - /> -

- Used only when creating a missing local branch. A - remote-tracking ref configures its upstream. -

-
- - )}
{createError && (

{ @@ -2021,21 +2187,23 @@ export function App() { [], ); + // Refire when the session identity or status changes, not on every agent + // update (which churns the selectedSession reference). Refiring on every + // reference races the in-flight RPC call against its successor; the + // failure of the latest generation would clear models and leave the picker + // stuck on the synthetic single-model fallback. React.useEffect(() => { + const sessionId = selectedSession?.id; + const status = selectedSession?.status; const generation = ++optionsGenerationRef.current; - if (!selectedSession || selectedSession.status === "offline") { + if (!sessionId || status === "offline") { setSessionOptions({ models: [], thinkingLevels: [], commands: [] }); return; } // get_session_options already includes commands; avoid a second connection // and native get_commands process spawn on every session selection. - void loadSessionOptions(selectedSession.id, generation); - }, [ - loadSessionOptions, - selectedSession?.id, - selectedSession?.status, - selectedSession, - ]); + void loadSessionOptions(sessionId, generation); + }, [loadSessionOptions, selectedSession?.id, selectedSession?.status]); const selectModel = React.useCallback( async (provider: string, modelId: string) => { @@ -2066,10 +2234,6 @@ export function App() { orderedSessions.filter((session) => sessionMatches(session, filterQuery)), [filterQuery, orderedSessions], ); - const repositorySuggestions = React.useMemo( - () => recentRepositories(sessions), - [sessions], - ); const sendSemanticPrompt = React.useCallback( async ( @@ -2506,8 +2670,6 @@ export function App() {

diff --git a/web/client/components/anchored-popover.tsx b/web/client/components/anchored-popover.tsx index e51edb2..8804803 100644 --- a/web/client/components/anchored-popover.tsx +++ b/web/client/components/anchored-popover.tsx @@ -10,8 +10,23 @@ type AnchoredPopoverProps = { children: React.ReactNode; className?: string; align?: "start" | "end"; + /** "auto" flips above the anchor when there is no room below; "below" stays under it. */ + placement?: "auto" | "below"; + /** Size the panel to the anchor's width instead of its content. */ + matchAnchorWidth?: boolean; }; +const MIN_PANEL_HEIGHT = 96; +const POPUP_GAP = 6; +const POPUP_MARGIN = 8; + +function panelMaxHeightCap(panel: HTMLElement | null): number | undefined { + if (!panel) return undefined; + const css = window.getComputedStyle(panel).maxHeight; + const parsed = css ? Number.parseFloat(css) : Number.NaN; + return Number.isNaN(parsed) ? undefined : parsed; +} + export function AnchoredPopover({ open, onOpenChange, @@ -19,9 +34,21 @@ export function AnchoredPopover({ children, className, align = "end", + placement = "auto", + matchAnchorWidth = false, }: AnchoredPopoverProps) { const panelRef = React.useRef(null); const [position, setPosition] = React.useState({ left: 8, top: 8 }); + const [anchorWidth, setAnchorWidth] = React.useState( + undefined, + ); + const [maxHeight, setMaxHeight] = React.useState( + undefined, + ); + // Computed maxHeight reflects our inline override once applied, so remember + // the stylesheet cap (e.g. max-h-64) separately to avoid ratcheting down. + const classMaxHeightRef = React.useRef(undefined); + const appliedMaxHeightRef = React.useRef(undefined); React.useLayoutEffect(() => { if (!open) return; @@ -31,17 +58,76 @@ export function AnchoredPopover({ const panel = panelRef.current; if (!anchor) return; const rect = anchor.getBoundingClientRect(); + setAnchorWidth((current) => + current === rect.width ? current : rect.width, + ); const viewport = window.visualViewport; + const viewportBox = { + offsetLeft: viewport?.offsetLeft ?? 0, + offsetTop: viewport?.offsetTop ?? 0, + width: viewport?.width ?? window.innerWidth, + height: viewport?.height ?? window.innerHeight, + }; + const panelWidth = matchAnchorWidth + ? rect.width + : (panel?.offsetWidth ?? 240); + if (placement === "below") { + // Prefer the conventional position under the field, but with the + // mobile keyboard open there may be no room: cap the panel height to + // the available space and flip above rather than covering the input. + const viewportBottom = viewportBox.offsetTop + viewportBox.height; + const roomBelow = + viewportBottom - POPUP_MARGIN - (rect.bottom + POPUP_GAP); + const roomAbove = + rect.top - POPUP_GAP - (viewportBox.offsetTop + POPUP_MARGIN); + const below = roomBelow >= MIN_PANEL_HEIGHT || roomBelow >= roomAbove; + const room = below ? roomBelow : roomAbove; + const computedCap = panelMaxHeightCap(panel); + if ( + computedCap !== undefined && + computedCap !== appliedMaxHeightRef.current + ) { + classMaxHeightRef.current = computedCap; + } + const cssCap = classMaxHeightRef.current; + const capped = Math.max( + Math.min(MIN_PANEL_HEIGHT, room), + Math.min(room, cssCap ?? Number.POSITIVE_INFINITY), + ); + appliedMaxHeightRef.current = capped; + setMaxHeight((current) => (current === capped ? current : capped)); + const desiredLeft = + align === "start" ? rect.left : rect.right - panelWidth; + const next = { + left: Math.max( + viewportBox.offsetLeft + POPUP_MARGIN, + Math.min( + viewportBox.offsetLeft + + viewportBox.width - + panelWidth - + POPUP_MARGIN, + desiredLeft, + ), + ), + top: below + ? rect.bottom + POPUP_GAP + : Math.max( + viewportBox.offsetTop + POPUP_MARGIN, + rect.top - POPUP_GAP - capped, + ), + }; + setPosition((current) => + current.left === next.left && current.top === next.top + ? current + : next, + ); + return; + } const next = anchoredPopoverPosition({ anchor: rect, - panelWidth: panel?.offsetWidth ?? 240, + panelWidth, panelHeight: panel?.offsetHeight ?? 200, - viewport: { - offsetLeft: viewport?.offsetLeft ?? 0, - offsetTop: viewport?.offsetTop ?? 0, - width: viewport?.width ?? window.innerWidth, - height: viewport?.height ?? window.innerHeight, - }, + viewport: viewportBox, align, }); setPosition((current) => @@ -74,7 +160,7 @@ export function AnchoredPopover({ viewport?.removeEventListener("resize", scheduleUpdate); viewport?.removeEventListener("scroll", scheduleUpdate); }; - }, [align, anchorRef, open]); + }, [align, anchorRef, matchAnchorWidth, open, placement]); React.useEffect(() => { if (!open) return; @@ -106,7 +192,15 @@ export function AnchoredPopover({ "fixed z-[70] rounded-lg border border-zinc-700 bg-zinc-950 p-1 shadow-2xl shadow-black/60", className, )} - style={position} + style={{ + ...position, + ...(matchAnchorWidth && anchorWidth !== undefined + ? { width: anchorWidth } + : {}), + ...(placement === "below" && maxHeight !== undefined + ? { maxHeight } + : {}), + }} > {children}
, diff --git a/web/client/components/ui/input.tsx b/web/client/components/ui/input.tsx index 2e47442..afbdd16 100644 --- a/web/client/components/ui/input.tsx +++ b/web/client/components/ui/input.tsx @@ -9,7 +9,8 @@ export const Input = React.forwardRef< { renameSync(staged.tombstone, staged.source); continue; } - const sourceQueue = persistedQueues.get(sourceId); + // replacement is only defined when sourceId is, but the find callback + // above loses that narrowing across the closure boundary. + const sourceQueue = sourceId ? persistedQueues.get(sourceId) : undefined; const replacementQueue = persistedQueues.get(replacement.session.id); if (sourceQueue?.length) { const ids = new Set(); @@ -2306,14 +2312,27 @@ async function routeCommandCore( location: "temporary", }); } + const scoped = record.scopedModels ?? []; + const scopedByKey = new Map( + scoped.map((s) => [`${s.provider}/${s.id}`, s]), + ); + const filterByScope = scopedByKey.size > 0; return { - models: models.map((model) => ({ - provider: String(model.provider ?? ""), - id: String(model.id ?? ""), - name: String(model.name ?? model.id ?? ""), - reasoning: model.reasoning === true, - thinkingLevels: levels, - })), + models: models + .filter((model) => + filterByScope + ? scopedByKey.has( + `${String(model.provider ?? "")}/${String(model.id ?? "")}`, + ) + : true, + ) + .map((model) => ({ + provider: String(model.provider ?? ""), + id: String(model.id ?? ""), + name: String(model.name ?? model.id ?? ""), + reasoning: model.reasoning === true, + thinkingLevels: levels, + })), thinkingLevels: levels, commands: webCommands, }; @@ -2519,6 +2538,11 @@ async function handleAgentMessage( record.preview = extractPreviewFromHistory(record.history) ?? record.preview; record.managedWorktree = helloManagedWorktree ?? record.managedWorktree; + // Carry the agent's --models scope onto the record so the model picker + // mirrors what the TUI would show. Empty array means no scope. + record.scopedModels = Array.isArray(hello.scopedModels) + ? hello.scopedModels + : undefined; record.agentSockets.add(socket); record.active = true; record.status = hello.session.status; @@ -2549,6 +2573,13 @@ async function handleAgentMessage( return; } if (!socket.data.authed) throw new Error("Agent must send agent.hello first"); + if (message.type === "agent.scope") { + const update = message as AgentScopeMessage; + const record = sessions.get(update.sessionId); + if (!record || !record.agentSockets.has(socket)) return; + record.scopedModels = update.scopedModels; + return; + } if (message.type === "agent.history") { const update = message as AgentHistoryMessage; const record = sessions.get(update.sessionId); @@ -3149,10 +3180,47 @@ async function handleApi(request: Request): Promise { commandHello: true, queueSteer: true, worktreeRefs: true, + branchSuggestions: true, }, tailscale: tailscaleStatus, }); } + if (request.method === "GET" && url.pathname === "/api/directories") { + return jsonResponse({ + directories: listDirectorySuggestions(url.searchParams.get("q") ?? "", { + baseDir: rootDir, + }), + }); + } + if (request.method === "GET" && url.pathname === "/api/branches") { + const requestedCwd = (url.searchParams.get("cwd") ?? "").trim(); + if (!requestedCwd) return badRequest("Missing cwd"); + let cwd: string; + try { + cwd = resolveWebCwd(requestedCwd, { baseDir: rootDir }); + } catch (error) { + return badRequest(error instanceof Error ? error.message : String(error)); + } + try { + if (!statSync(cwd).isDirectory()) + return badRequest(`cwd is not a directory: ${cwd}`); + } catch { + return badRequest(`cwd does not exist: ${cwd}`); + } + let branches: RepositoryBranches; + try { + branches = listRepositoryBranches(cwd); + } catch (error) { + // Not a Git repository (or Git is unavailable): the browser just shows + // no suggestions instead of surfacing an error mid-typing. + return jsonResponse({ + local: [], + remote: [], + error: error instanceof Error ? error.message : String(error), + }); + } + return jsonResponse(branches); + } if (request.method === "POST" && url.pathname === "/api/tailscale") { const body = (await request.json().catch(() => undefined)) as | { diff --git a/web/server/server-types.ts b/web/server/server-types.ts index 1019947..7ed32d0 100644 --- a/web/server/server-types.ts +++ b/web/server/server-types.ts @@ -81,4 +81,6 @@ export type SessionRecord = { pendingWorktreeSourceDeletion?: { sessionId: string; sessionFile: string }; catalogReady?: boolean; gitMetadataGeneration?: number; + /** Models the agent's session is scoped to via --models. Server-internal. */ + scopedModels?: import("../protocol.js").WebScopedModel[]; }; diff --git a/web/server/suggestions.ts b/web/server/suggestions.ts new file mode 100644 index 0000000..16d985b --- /dev/null +++ b/web/server/suggestions.ts @@ -0,0 +1,98 @@ +import { type Dirent, readdirSync, statSync } from "node:fs"; +import { homedir } from "node:os"; +import { basename, dirname, join, relative, resolve } from "node:path"; +import { resolveWebCwd } from "./paths.js"; +import { resolveSessionProject } from "./projects.js"; + +const MAX_DIRECTORY_SUGGESTIONS = 20; + +export type DirectorySuggestionOptions = { + baseDir?: string; + homeDir?: string; +}; + +function isDirectory(path: string): boolean { + try { + return statSync(path).isDirectory(); + } catch { + return false; + } +} + +/** Format a directory for the browser using the ~ shorthand whenever possible. */ +function displayPath(path: string, home: string): string { + const relation = relative(home, path); + if (relation === "") return "~"; + if (!relation.startsWith("..") && !relation.startsWith("/")) { + return `~/${relation.split("\\").join("/")}`; + } + return path; +} + +/** + * Suggest directories that continue the typed path. + * + * The last path segment is treated as a prefix filter over the parent + * directory's entries, so "~/vess" suggests ~/vessup rather than listing it. + * A trailing slash lists the named directory's children so completions can + * drill down level by level. Suggestions stop once the listed directory is + * inside a Git repository: the field selects a repository, so paths beneath + * one are noise. Missing or unreadable directories return no suggestions + * instead of failing. + */ +export function listDirectorySuggestions( + query: string, + options: DirectorySuggestionOptions = {}, +): string[] { + const home = resolve(options.homeDir ?? homedir()); + const trimmed = query.trim(); + let parent: string; + let prefix: string; + if (!trimmed || trimmed === "~") { + parent = home; + prefix = ""; + } else { + let resolved: string; + try { + resolved = resolveWebCwd(trimmed, { + baseDir: options.baseDir, + homeDir: options.homeDir, + }); + } catch { + // Unsupported shorthand such as ~user cannot be autocompleted. + return []; + } + if (trimmed.endsWith("/")) { + parent = resolved; + prefix = ""; + } else { + parent = dirname(resolved); + prefix = basename(resolved); + } + } + if (!isDirectory(parent)) return []; + if (resolveSessionProject(parent).id.startsWith("git:")) return []; + const showHidden = prefix.startsWith("."); + let entries: Dirent[] = []; + try { + entries = readdirSync(parent, { withFileTypes: true }); + } catch { + return []; + } + const lowered = prefix.toLowerCase(); + const matches = entries + .filter((entry) => { + if (!showHidden && entry.name.startsWith(".")) return false; + if (!entry.name.toLowerCase().startsWith(lowered)) return false; + if (entry.isDirectory()) return true; + // Follow symlinks to directories so linked checkouts autocomplete. + if (entry.name.startsWith(".")) return false; + return isDirectory(join(parent, entry.name)); + }) + .map((entry) => entry.name) + .sort((left, right) => + left.toLowerCase().localeCompare(right.toLowerCase()), + ) + .slice(0, MAX_DIRECTORY_SUGGESTIONS); + return matches.map((name) => displayPath(join(parent, name), home)); +} diff --git a/web/server/worktrees.ts b/web/server/worktrees.ts index 6d2458f..e2e800a 100644 --- a/web/server/worktrees.ts +++ b/web/server/worktrees.ts @@ -200,6 +200,36 @@ async function ensureSupportedGitAsync(): Promise { } export type WorktreeRef = { kind: "branch" | "detached"; value: string }; + +export type RepositoryBranches = { + /** Short local branch names, e.g. "main" or "owner/topic". */ + local: string[]; + /** Remote-tracking branches including their remote prefix, e.g. "origin/main". */ + remote: string[]; +}; + +/** List local and remote-tracking branches for autocomplete in the browser. */ +export function listRepositoryBranches(cwd: string): RepositoryBranches { + const output = gitOutput(cwd, [ + "for-each-ref", + "--format=%(refname)", + "refs/heads", + "refs/remotes", + ]); + const local: string[] = []; + const remote: string[] = []; + for (const ref of output.split("\n")) { + if (ref.startsWith("refs/heads/")) + local.push(ref.slice("refs/heads/".length)); + else if (ref.startsWith("refs/remotes/")) { + const short = ref.slice("refs/remotes/".length); + // The symbolic remote HEAD is not a branch anyone can check out. + if (!short.endsWith("/HEAD")) remote.push(short); + } + } + return { local, remote }; +} + export type ExistingWebWorktree = { path: string; repoRoot: string;