From bb93c0fef1b16459ded0c4e72eb0d8ac85d9542b Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Thu, 7 May 2026 12:05:28 +0200 Subject: [PATCH] Add prompt autocomplete and session status --- src/client/src/api.ts | 27 +++ src/client/src/appState.ts | 24 +++ src/client/src/components/AutocompleteMenu.ts | 27 +++ src/client/src/components/FormattedText.ts | 41 +--- src/client/src/components/PiWebApp.ts | 177 +++++------------- src/client/src/components/PromptEditor.ts | 121 ++++++++++++ src/client/src/components/StatusBar.ts | 39 ++++ src/client/src/components/shared.ts | 37 +++- .../src/controllers/projectController.ts | 29 +++ .../src/controllers/sessionController.ts | 81 ++++++++ src/client/src/controllers/types.ts | 11 ++ .../src/controllers/workspaceController.ts | 41 ++++ src/client/src/formatting/markdown.ts | 40 ++++ src/client/src/sessionSocket.ts | 5 +- src/client/src/utils/format.ts | 14 ++ src/server/index.ts | 26 +++ src/server/sessions/piSessionService.ts | 71 ++++++- src/server/types.ts | 24 +++ src/server/workspaces/fileSuggestions.ts | 65 +++++++ 19 files changed, 726 insertions(+), 174 deletions(-) create mode 100644 src/client/src/appState.ts create mode 100644 src/client/src/components/AutocompleteMenu.ts create mode 100644 src/client/src/components/PromptEditor.ts create mode 100644 src/client/src/components/StatusBar.ts create mode 100644 src/client/src/controllers/projectController.ts create mode 100644 src/client/src/controllers/sessionController.ts create mode 100644 src/client/src/controllers/types.ts create mode 100644 src/client/src/controllers/workspaceController.ts create mode 100644 src/client/src/formatting/markdown.ts create mode 100644 src/client/src/utils/format.ts create mode 100644 src/server/workspaces/fileSuggestions.ts diff --git a/src/client/src/api.ts b/src/client/src/api.ts index 9f77838..f1fa18b 100644 --- a/src/client/src/api.ts +++ b/src/client/src/api.ts @@ -26,6 +26,30 @@ export interface SessionInfo { firstMessage: string; } +export interface SessionStatus { + sessionId: string; + model?: { provider?: string; id?: string; name?: string; contextWindow?: number; reasoning?: unknown }; + thinkingLevel?: string; + isStreaming: boolean; + isCompacting: boolean; + isBashRunning: boolean; + pendingMessageCount: number; + tokens: { input: number; output: number; cacheRead: number; cacheWrite: number; total: number }; + cost: number; + contextUsage?: { tokens: number | null; contextWindow: number; percent: number | null }; +} + +export interface SlashCommand { + name: string; + description?: string; + source: "extension" | "prompt" | "skill" | "builtin"; +} + +export interface FileSuggestion { + path: string; + kind: "tracked" | "untracked" | "other"; +} + async function request(url: string, init?: RequestInit): Promise { const response = await fetch(url, { ...init, @@ -45,6 +69,9 @@ export const api = { sessions: (cwd: string) => request(`/api/sessions?cwd=${encodeURIComponent(cwd)}`), startSession: (cwd: string) => request("/api/sessions", { method: "POST", body: JSON.stringify({ cwd }) }), messages: (sessionId: string) => request(`/api/sessions/${sessionId}/messages`), + status: (sessionId: string) => request(`/api/sessions/${sessionId}/status`), + commands: (sessionId: string) => request(`/api/sessions/${sessionId}/commands`), + files: (cwd: string, query: string, kind?: FileSuggestion["kind"]) => request(`/api/files?cwd=${encodeURIComponent(cwd)}&q=${encodeURIComponent(query)}${kind ? `&kind=${encodeURIComponent(kind)}` : ""}`), prompt: (sessionId: string, text: string) => request<{ accepted: true }>(`/api/sessions/${sessionId}/prompt`, { method: "POST", body: JSON.stringify({ text }) }), close: (sessionId: string) => request<{ closed: true }>(`/api/sessions/${sessionId}/close`, { method: "POST" }), }; diff --git a/src/client/src/appState.ts b/src/client/src/appState.ts new file mode 100644 index 0000000..bf73abb --- /dev/null +++ b/src/client/src/appState.ts @@ -0,0 +1,24 @@ +import type { Project, SessionInfo, SessionStatus, Workspace } from "./api"; +import type { ChatLine } from "./components/shared"; + +export interface AppState { + projects: Project[]; + workspaces: Workspace[]; + sessions: SessionInfo[]; + messages: ChatLine[]; + selectedProject?: Project; + selectedWorkspace?: Workspace; + selectedSession?: SessionInfo; + status?: SessionStatus; + error: string; +} + +export function initialAppState(): AppState { + return { + projects: [], + workspaces: [], + sessions: [], + messages: [], + error: "", + }; +} diff --git a/src/client/src/components/AutocompleteMenu.ts b/src/client/src/components/AutocompleteMenu.ts new file mode 100644 index 0000000..e8d075c --- /dev/null +++ b/src/client/src/components/AutocompleteMenu.ts @@ -0,0 +1,27 @@ +import { LitElement, html } from "lit"; +import { customElement, property } from "lit/decorators.js"; +import { autocompleteStyles, type CompletionItem } from "./shared"; + +@customElement("autocomplete-menu") +export class AutocompleteMenu extends LitElement { + @property({ attribute: false }) items: CompletionItem[] = []; + @property({ type: Number }) selectedIndex = 0; + @property({ attribute: false }) onPick?: (item: CompletionItem) => void; + + render() { + if (!this.items.length) return null; + return html` + + `; + } + + static styles = autocompleteStyles; +} diff --git a/src/client/src/components/FormattedText.ts b/src/client/src/components/FormattedText.ts index 917a69e..a6d8250 100644 --- a/src/client/src/components/FormattedText.ts +++ b/src/client/src/components/FormattedText.ts @@ -1,7 +1,7 @@ import { LitElement, html } from "lit"; import { customElement, property } from "lit/decorators.js"; import { unsafeHTML } from "lit/directives/unsafe-html.js"; -import { marked } from "marked"; +import { toSafeMarkdownHtml } from "../formatting/markdown"; import { formattedTextStyles } from "./shared"; @customElement("formatted-text") @@ -14,42 +14,3 @@ export class FormattedText extends LitElement { static styles = formattedTextStyles; } - -function toSafeMarkdownHtml(text: string): string { - const html = marked.parse(escapeHtml(text), { async: false, breaks: true, gfm: true }) as string; - return sanitizeHtml(html); -} - -function escapeHtml(text: string): string { - return text - .replaceAll("&", "&") - .replaceAll("<", "<") - .replaceAll(">", ">"); -} - -function sanitizeHtml(html: string): string { - const template = document.createElement("template"); - template.innerHTML = html; - template.content.querySelectorAll("script, style, iframe, object, embed").forEach((node) => node.remove()); - template.content.querySelectorAll("*").forEach((element) => { - for (const attribute of [...element.attributes]) { - const name = attribute.name.toLowerCase(); - if (name.startsWith("on")) element.removeAttribute(attribute.name); - if ((name === "href" || name === "src") && !isSafeUrl(attribute.value)) element.removeAttribute(attribute.name); - } - if (element.tagName === "A") { - element.setAttribute("target", "_blank"); - element.setAttribute("rel", "noreferrer noopener"); - } - }); - return template.innerHTML; -} - -function isSafeUrl(url: string): boolean { - if (url.startsWith("#") || url.startsWith("/")) return true; - try { - return ["http:", "https:", "mailto:"].includes(new URL(url).protocol); - } catch { - return false; - } -} diff --git a/src/client/src/components/PiWebApp.ts b/src/client/src/components/PiWebApp.ts index 1e27e78..1740763 100644 --- a/src/client/src/components/PiWebApp.ts +++ b/src/client/src/components/PiWebApp.ts @@ -1,176 +1,97 @@ import { LitElement, html } from "lit"; import { customElement, state } from "lit/decorators.js"; -import { api, type Project, type SessionInfo, type Workspace } from "../api"; +import type { Project, SessionInfo, Workspace } from "../api"; +import { initialAppState, type AppState } from "../appState"; +import { ProjectController } from "../controllers/projectController"; +import { SessionController } from "../controllers/sessionController"; +import { WorkspaceController } from "../controllers/workspaceController"; import { readRoute, writeRoute } from "../route"; -import { SessionSocket, type SessionUiEvent } from "../sessionSocket"; import "./ProjectList"; import "./WorkspaceList"; import "./SessionList"; import "./ChatView"; -import "./Composer"; -import { normalizeMessages, appendText, textMessage } from "../chatMessages"; -import { appStyles, type ChatLine } from "./shared"; +import "./PromptEditor"; +import "./StatusBar"; +import { appStyles } from "./shared"; @customElement("pi-web-poc") export class PiWebApp extends LitElement { - @state() private projects: Project[] = []; - @state() private workspaces: Workspace[] = []; - @state() private sessions: SessionInfo[] = []; - @state() private messages: ChatLine[] = []; - @state() private selectedProject?: Project; - @state() private selectedWorkspace?: Workspace; - @state() private selectedSession?: SessionInfo; - @state() private error = ""; + @state() private state: AppState = initialAppState(); - private readonly socket = new SessionSocket(); + private readonly sessions = new SessionController( + () => this.state, + (patch) => this.setState(patch), + () => this.updateUrl(), + ); + private readonly workspaces = new WorkspaceController( + () => this.state, + (patch) => this.setState(patch), + () => this.updateUrl(), + this.sessions, + ); + private readonly projects = new ProjectController( + () => this.state, + (patch) => this.setState(patch), + this.workspaces, + ); private readonly onPopState = () => void this.restoreRoute(false); connectedCallback(): void { super.connectedCallback(); window.addEventListener("popstate", this.onPopState); - void this.loadProjects(); + void this.loadProjectsAndRestoreRoute(); } disconnectedCallback(): void { window.removeEventListener("popstate", this.onPopState); - this.socket.close(); + this.sessions.dispose(); super.disconnectedCallback(); } - private async loadProjects() { - this.error = ""; - try { - this.projects = await api.projects(); - await this.restoreRoute(false); - } catch (error) { - this.error = String(error); - } + private setState(patch: Partial) { + this.state = { ...this.state, ...patch }; } - private async addProject() { - const path = prompt("Project folder path"); - if (!path) return; - try { - const project = await api.addProject(path); - this.projects = [...this.projects.filter((p) => p.id !== project.id), project]; - await this.selectProject(project); - } catch (error) { - this.error = String(error); - } - } - - private async selectProject(project: Project, target?: { workspaceId?: string; sessionId?: string; updateUrl?: boolean }) { - this.selectedProject = project; - this.selectedWorkspace = undefined; - this.selectedSession = undefined; - this.sessions = []; - this.messages = []; - this.socket.close(); - try { - this.workspaces = await api.workspaces(project.id); - const workspace = target?.workspaceId ? this.workspaces.find((w) => w.id === target.workspaceId) : this.workspaces[0]; - if (workspace) await this.selectWorkspace(workspace, { sessionId: target?.sessionId, updateUrl: target?.updateUrl }); - else if (target?.updateUrl !== false) this.updateUrl(); - } catch (error) { - this.error = String(error); - } - } - - private async selectWorkspace(workspace: Workspace, target?: { sessionId?: string; updateUrl?: boolean }) { - this.selectedWorkspace = workspace; - this.selectedSession = undefined; - this.messages = []; - this.socket.close(); - try { - this.sessions = await api.sessions(workspace.path); - const sessionId = target?.sessionId; - const session = sessionId ? this.sessions.find((s) => s.id === sessionId || s.id.startsWith(sessionId)) : undefined; - if (session) await this.selectSession(session, { updateUrl: target?.updateUrl }); - else if (target?.updateUrl !== false) this.updateUrl(); - } catch (error) { - this.error = String(error); - } - } - - private async startSession() { - if (!this.selectedWorkspace) return; - try { - const session = await api.startSession(this.selectedWorkspace.path); - this.sessions = [session, ...this.sessions]; - await this.selectSession(session); - } catch (error) { - this.error = String(error); - } - } - - private async selectSession(session: SessionInfo, options?: { updateUrl?: boolean }) { - this.selectedSession = session; - this.socket.close(); - this.messages = normalizeMessages(await api.messages(session.id)); - this.socket.connect(session.id, (event) => this.applyEvent(event)); - if (options?.updateUrl !== false) this.updateUrl(); - } - - private applyEvent(event: SessionUiEvent) { - if (event.type === "assistant.delta") { - this.messages = appendText(this.messages, "assistant", event.text); - } else if (event.type === "tool.start") { - this.messages = [...this.messages, { role: "tool", parts: [{ type: "toolCall", toolName: event.toolName, summary: "" }] }]; - } else if (event.type === "tool.end") { - this.messages = [...this.messages, textMessage("tool", `${event.isError ? "✖" : "✓"} ${event.toolName}`)]; - } else if (event.type === "session.error") { - this.messages = [...this.messages, textMessage("system", event.message)]; - } - } - - private async send(text: string) { - if (!this.selectedSession) return; - this.messages = [...this.messages, textMessage("user", text)]; - try { - await api.prompt(this.selectedSession.id, text); - } catch (error) { - this.error = String(error); - } - } - - private async closeSession() { - if (!this.selectedSession) return; - await api.close(this.selectedSession.id); - this.selectedSession = undefined; - this.socket.close(); - this.messages = []; - this.updateUrl(); + private async loadProjectsAndRestoreRoute() { + await this.projects.loadProjects(); + await this.restoreRoute(false); } private async restoreRoute(updateUrl: boolean) { const route = readRoute(); if (!route.projectId) return; - const project = this.projects.find((p) => p.id === route.projectId); + const project = this.state.projects.find((p) => p.id === route.projectId); if (!project) return; - await this.selectProject(project, { workspaceId: route.workspaceId, sessionId: route.sessionId, updateUrl }); + await this.workspaces.selectProject(project, { workspaceId: route.workspaceId, sessionId: route.sessionId, updateUrl }); } private updateUrl() { - writeRoute({ projectId: this.selectedProject?.id, workspaceId: this.selectedWorkspace?.id, sessionId: this.selectedSession?.id }); + writeRoute({ + projectId: this.state.selectedProject?.id, + workspaceId: this.state.selectedWorkspace?.id, + sessionId: this.state.selectedSession?.id, + }); } render() { + const state = this.state; return html`
- ${this.error ? html`
${this.error}
` : null} - ${this.selectedSession ? html` - - this.send(text)} .onCloseSession=${() => this.closeSession()}> + ${state.error ? html`
${state.error}
` : null} + ${state.selectedSession ? html` + + + this.sessions.send(text)} .onCloseSession=${() => this.sessions.closeSession()}> ` : html`
Select or start a session.
`}
diff --git a/src/client/src/components/PromptEditor.ts b/src/client/src/components/PromptEditor.ts new file mode 100644 index 0000000..c250961 --- /dev/null +++ b/src/client/src/components/PromptEditor.ts @@ -0,0 +1,121 @@ +import { LitElement, html } from "lit"; +import { customElement, property, state } from "lit/decorators.js"; +import { api, type FileSuggestion, type SlashCommand } from "../api"; +import { promptEditorStyles, type CompletionItem } from "./shared"; +import "./AutocompleteMenu"; + +@customElement("prompt-editor") +export class PromptEditor extends LitElement { + @property({ type: Boolean }) disabled = false; + @property() sessionId?: string; + @property() cwd?: string; + @property({ attribute: false }) onSend?: (text: string) => void; + @property({ attribute: false }) onCloseSession?: () => void; + @state() private draft = ""; + @state() private completions: CompletionItem[] = []; + @state() private selectedIndex = 0; + private requestVersion = 0; + + render() { + return html` +
+
+ + this.pick(item)}> +
+ + +
+ `; + } + + private updateDraft(value: string) { + this.draft = value; + void this.refreshCompletions(); + } + + private async refreshCompletions() { + const trigger = this.currentTrigger(); + const version = ++this.requestVersion; + this.selectedIndex = 0; + if (!trigger) { + this.completions = []; + return; + } + if (trigger.kind === "command" && this.sessionId) { + const commands = await api.commands(this.sessionId).catch(() => [] as SlashCommand[]); + if (version !== this.requestVersion) return; + this.completions = commands + .filter((command) => command.name.toLowerCase().includes(trigger.query.toLowerCase())) + .slice(0, 12) + .map((command) => ({ kind: "command", replaceFrom: trigger.from, replaceTo: this.draft.length, insertText: `/${command.name}`, detail: command.source, description: command.description })); + } else if (trigger.kind === "file" && this.cwd) { + const files = await api.files(this.cwd, trigger.query, trigger.fileKind).catch(() => [] as FileSuggestion[]); + if (version !== this.requestVersion) return; + this.completions = files + .slice(0, 12) + .map((file) => ({ kind: "file", replaceFrom: trigger.from, replaceTo: this.draft.length, insertText: `@${file.path}`, detail: file.kind })); + } + } + + private currentTrigger(): { kind: "command" | "file"; query: string; from: number; fileKind?: FileSuggestion["kind"] } | undefined { + const beforeCursor = this.draft; + if (beforeCursor.endsWith("@ ")) return { kind: "file", query: "", from: beforeCursor.length - 2, fileKind: "untracked" }; + + const tokenStart = Math.max(beforeCursor.lastIndexOf(" "), beforeCursor.lastIndexOf("\n")) + 1; + const token = beforeCursor.slice(tokenStart); + if (token.startsWith("/")) return { kind: "command", query: token.slice(1), from: tokenStart }; + if (token.startsWith("@")) return { kind: "file", query: token.slice(1), from: tokenStart }; + return undefined; + } + + private handleKeyDown(event: KeyboardEvent) { + if (this.completions.length) { + if (event.key === "ArrowDown") { + event.preventDefault(); + this.selectedIndex = (this.selectedIndex + 1) % this.completions.length; + return; + } + if (event.key === "ArrowUp") { + event.preventDefault(); + this.selectedIndex = (this.selectedIndex - 1 + this.completions.length) % this.completions.length; + return; + } + if (event.key === "Tab" || event.key === "Enter") { + event.preventDefault(); + this.pick(this.completions[this.selectedIndex]); + return; + } + if (event.key === "Escape") { + event.preventDefault(); + this.completions = []; + return; + } + } + if (event.key === "Enter" && !event.shiftKey) { + event.preventDefault(); + this.send(); + } + } + + private pick(item: CompletionItem) { + this.draft = `${this.draft.slice(0, item.replaceFrom)}${item.insertText} ${this.draft.slice(item.replaceTo)}`; + this.completions = []; + } + + private send() { + const text = this.draft.trim(); + if (!text || this.disabled) return; + this.draft = ""; + this.completions = []; + this.onSend?.(text); + } + + static styles = promptEditorStyles; +} diff --git a/src/client/src/components/StatusBar.ts b/src/client/src/components/StatusBar.ts new file mode 100644 index 0000000..5aae132 --- /dev/null +++ b/src/client/src/components/StatusBar.ts @@ -0,0 +1,39 @@ +import { LitElement, html } from "lit"; +import { customElement, property } from "lit/decorators.js"; +import type { SessionStatus, Workspace } from "../api"; +import { formatCost, formatTokenCount } from "../utils/format"; +import { statusBarStyles } from "./shared"; + +@customElement("status-bar") +export class StatusBar extends LitElement { + @property({ attribute: false }) status?: SessionStatus; + @property({ attribute: false }) workspace?: Workspace; + + render() { + const status = this.status; + if (!status) return html`
No session status yet
`; + const model = status.model?.id ?? "no model"; + const provider = status.model?.provider ? `${status.model.provider}/` : ""; + const state = status.isCompacting ? "compacting" : status.isBashRunning ? "bash" : status.isStreaming ? "running" : "idle"; + const context = status.contextUsage; + const contextText = context + ? `${context.percent == null ? "?" : context.percent.toFixed(1)}%/${formatTokenCount(context.contextWindow)}` + : "context ?"; + const tokens = status.tokens; + return html` +
+ ${this.workspace?.label ?? "workspace"} + ${state} + ${provider}${model} + thinking ${status.thinkingLevel ?? "off"} + ↑${formatTokenCount(tokens.input)} + ↓${formatTokenCount(tokens.output)} + ${contextText} + ${formatCost(status.cost)} + ${status.pendingMessageCount ? html`${status.pendingMessageCount} queued` : null} +
+ `; + } + + static styles = statusBarStyles; +} diff --git a/src/client/src/components/shared.ts b/src/client/src/components/shared.ts index 0ecc2b9..d40064d 100644 --- a/src/client/src/components/shared.ts +++ b/src/client/src/components/shared.ts @@ -12,6 +12,15 @@ export interface ChatLine { parts: ChatPart[]; } +export interface CompletionItem { + kind: "command" | "file"; + replaceFrom: number; + replaceTo: number; + insertText: string; + detail: string; + description?: string; +} + export const appStyles = css` :host { display: block; height: 100vh; color: #e6edf3; background: #0d1117; font: 14px system-ui, sans-serif; } .shell { display: grid; grid-template-columns: 340px 1fr; height: 100%; min-height: 0; } @@ -20,8 +29,9 @@ export const appStyles = css` project-list, workspace-list { flex: 0 0 auto; max-height: 26%; overflow: auto; border-bottom: 1px solid #21262d; } session-list { flex: 1 1 auto; min-height: 0; overflow: auto; } main { display: flex; flex-direction: column; min-width: 0; min-height: 0; } + status-bar { flex: 0 0 auto; } chat-view { flex: 1 1 auto; min-height: 0; overflow: auto; } - chat-composer { flex: 0 0 auto; } + prompt-editor, chat-composer { flex: 0 0 auto; } button { border: 1px solid #30363d; border-radius: 8px; background: #161b22; color: #e6edf3; padding: 7px 9px; cursor: pointer; } .empty { margin: auto; color: #8b949e; } .error { padding: 10px 16px; border-bottom: 1px solid #30363d; color: #ff7b72; } @@ -78,10 +88,31 @@ export const formattedTextStyles = css` th { background: #161b22; } `; -export const composerStyles = css` +export const statusBarStyles = css` + :host { display: block; color: #8b949e; font: 12px system-ui, sans-serif; } + .bar { display: flex; gap: 12px; align-items: center; min-width: 0; padding: 7px 12px; border-bottom: 1px solid #30363d; background: #0d1117; white-space: nowrap; overflow: hidden; } + span { overflow: hidden; text-overflow: ellipsis; } + span:first-child { flex: 1 1 auto; min-width: 80px; } + .muted { color: #6e7681; } +`; + +export const autocompleteStyles = css` + :host { display: block; } + .menu { position: absolute; left: 0; right: 0; bottom: calc(100% + 6px); max-height: 260px; overflow: auto; border: 1px solid #30363d; border-radius: 8px; background: #161b22; box-shadow: 0 10px 30px #0008; } + button { display: grid; grid-template-columns: minmax(120px, 1fr) auto; gap: 4px 10px; width: 100%; border: 0; border-bottom: 1px solid #30363d; border-radius: 0; background: transparent; color: #e6edf3; padding: 8px 10px; text-align: left; cursor: pointer; } + button:last-child { border-bottom: 0; } + button.selected, button:hover { background: #0d2847; } + span { color: #8b949e; font-size: 12px; } + small { grid-column: 1 / -1; color: #8b949e; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +`; + +export const promptEditorStyles = css` :host { display: block; color: #e6edf3; font: 14px system-ui, sans-serif; } footer { display: grid; grid-template-columns: 1fr auto auto; gap: 8px; padding: 12px; border-top: 1px solid #30363d; } - textarea { min-height: 54px; resize: vertical; border-radius: 8px; border: 1px solid #30363d; background: #0d1117; color: #e6edf3; padding: 8px; } + .editor-wrap { position: relative; min-width: 0; } + textarea { box-sizing: border-box; width: 100%; min-height: 54px; resize: vertical; border-radius: 8px; border: 1px solid #30363d; background: #0d1117; color: #e6edf3; padding: 8px; } button { border: 1px solid #30363d; border-radius: 8px; background: #161b22; color: #e6edf3; padding: 7px 9px; cursor: pointer; } button:disabled, textarea:disabled { opacity: .5; cursor: not-allowed; } `; + +export const composerStyles = promptEditorStyles; diff --git a/src/client/src/controllers/projectController.ts b/src/client/src/controllers/projectController.ts new file mode 100644 index 0000000..65eaf1b --- /dev/null +++ b/src/client/src/controllers/projectController.ts @@ -0,0 +1,29 @@ +import { api } from "../api"; +import type { GetState, SetState } from "./types"; +import type { WorkspaceController } from "./workspaceController"; + +export class ProjectController { + constructor(private readonly getState: GetState, private readonly setState: SetState, private readonly workspaces: WorkspaceController) {} + + async loadProjects() { + this.setState({ error: "" }); + try { + this.setState({ projects: await api.projects() }); + } catch (error) { + this.setState({ error: String(error) }); + } + } + + async addProject() { + const path = prompt("Project folder path"); + if (!path) return; + try { + const project = await api.addProject(path); + const projects = this.getState().projects; + this.setState({ projects: [...projects.filter((p) => p.id !== project.id), project] }); + await this.workspaces.selectProject(project); + } catch (error) { + this.setState({ error: String(error) }); + } + } +} diff --git a/src/client/src/controllers/sessionController.ts b/src/client/src/controllers/sessionController.ts new file mode 100644 index 0000000..9f89878 --- /dev/null +++ b/src/client/src/controllers/sessionController.ts @@ -0,0 +1,81 @@ +import { api, type SessionInfo } from "../api"; +import { appendText, normalizeMessages, textMessage } from "../chatMessages"; +import { SessionSocket, type SessionUiEvent } from "../sessionSocket"; +import type { GetState, SetState, UpdateUrl } from "./types"; + +export class SessionController { + private readonly socket = new SessionSocket(); + + constructor(private readonly getState: GetState, private readonly setState: SetState, private readonly updateUrl: UpdateUrl) {} + + dispose() { + this.socket.close(); + } + + clearActiveSession() { + this.socket.close(); + this.setState({ selectedSession: undefined, messages: [], status: undefined }); + } + + async startSession() { + const workspace = this.getState().selectedWorkspace; + if (!workspace) return; + try { + const session = await api.startSession(workspace.path); + this.setState({ sessions: [session, ...this.getState().sessions] }); + await this.selectSession(session); + } catch (error) { + this.setState({ error: String(error) }); + } + } + + async selectSession(session: SessionInfo, options?: { updateUrl?: boolean }) { + this.socket.close(); + try { + this.setState({ selectedSession: session, messages: normalizeMessages(await api.messages(session.id)), status: await api.status(session.id) }); + this.socket.connect(session.id, (event) => this.applyEvent(event)); + if (options?.updateUrl !== false) this.updateUrl(); + } catch (error) { + this.setState({ error: String(error) }); + } + } + + async send(text: string) { + const session = this.getState().selectedSession; + if (!session) return; + this.setState({ messages: [...this.getState().messages, textMessage("user", text)] }); + try { + await api.prompt(session.id, text); + } catch (error) { + this.setState({ error: String(error) }); + } + } + + async closeSession() { + const session = this.getState().selectedSession; + if (!session) return; + try { + await api.close(session.id); + } catch (error) { + this.setState({ error: String(error) }); + } finally { + this.clearActiveSession(); + this.updateUrl(); + } + } + + private applyEvent(event: SessionUiEvent) { + const messages = this.getState().messages; + if (event.type === "assistant.delta") { + this.setState({ messages: appendText(messages, "assistant", event.text) }); + } else if (event.type === "tool.start") { + this.setState({ messages: [...messages, { role: "tool", parts: [{ type: "toolCall", toolName: event.toolName, summary: "" }] }] }); + } else if (event.type === "tool.end") { + this.setState({ messages: [...messages, textMessage("tool", `${event.isError ? "✖" : "✓"} ${event.toolName}`)] }); + } else if (event.type === "status.update") { + this.setState({ status: event.status }); + } else if (event.type === "session.error") { + this.setState({ messages: [...messages, textMessage("system", event.message)] }); + } + } +} diff --git a/src/client/src/controllers/types.ts b/src/client/src/controllers/types.ts new file mode 100644 index 0000000..f830c88 --- /dev/null +++ b/src/client/src/controllers/types.ts @@ -0,0 +1,11 @@ +import type { AppState } from "../appState"; + +export type GetState = () => AppState; +export type SetState = (patch: Partial) => void; +export type UpdateUrl = () => void; + +export interface RouteTarget { + workspaceId?: string; + sessionId?: string; + updateUrl?: boolean; +} diff --git a/src/client/src/controllers/workspaceController.ts b/src/client/src/controllers/workspaceController.ts new file mode 100644 index 0000000..0285202 --- /dev/null +++ b/src/client/src/controllers/workspaceController.ts @@ -0,0 +1,41 @@ +import { api, type Project, type Workspace } from "../api"; +import type { GetState, RouteTarget, SetState, UpdateUrl } from "./types"; +import type { SessionController } from "./sessionController"; + +export class WorkspaceController { + constructor( + private readonly getState: GetState, + private readonly setState: SetState, + private readonly updateUrl: UpdateUrl, + private readonly sessions: SessionController, + ) {} + + async selectProject(project: Project, target?: RouteTarget) { + this.sessions.clearActiveSession(); + this.setState({ selectedProject: project, selectedWorkspace: undefined, sessions: [], workspaces: [], error: "" }); + try { + const workspaces = await api.workspaces(project.id); + this.setState({ workspaces }); + const workspace = target?.workspaceId ? workspaces.find((w) => w.id === target.workspaceId) : workspaces[0]; + if (workspace) await this.selectWorkspace(workspace, { sessionId: target?.sessionId, updateUrl: target?.updateUrl }); + else if (target?.updateUrl !== false) this.updateUrl(); + } catch (error) { + this.setState({ error: String(error) }); + } + } + + async selectWorkspace(workspace: Workspace, target?: { sessionId?: string; updateUrl?: boolean }) { + this.sessions.clearActiveSession(); + this.setState({ selectedWorkspace: workspace, sessions: [], error: "" }); + try { + const sessions = await api.sessions(workspace.path); + this.setState({ sessions }); + const sessionId = target?.sessionId; + const session = sessionId ? sessions.find((s) => s.id === sessionId || s.id.startsWith(sessionId)) : undefined; + if (session) await this.sessions.selectSession(session, { updateUrl: target?.updateUrl }); + else if (target?.updateUrl !== false) this.updateUrl(); + } catch (error) { + this.setState({ error: String(error) }); + } + } +} diff --git a/src/client/src/formatting/markdown.ts b/src/client/src/formatting/markdown.ts new file mode 100644 index 0000000..e3ea5d5 --- /dev/null +++ b/src/client/src/formatting/markdown.ts @@ -0,0 +1,40 @@ +import { marked } from "marked"; + +export function toSafeMarkdownHtml(text: string): string { + const html = marked.parse(escapeHtml(text), { async: false, breaks: true, gfm: true }) as string; + return sanitizeHtml(html); +} + +function escapeHtml(text: string): string { + return text + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">"); +} + +function sanitizeHtml(html: string): string { + const template = document.createElement("template"); + template.innerHTML = html; + template.content.querySelectorAll("script, style, iframe, object, embed").forEach((node) => node.remove()); + template.content.querySelectorAll("*").forEach((element) => { + for (const attribute of [...element.attributes]) { + const name = attribute.name.toLowerCase(); + if (name.startsWith("on")) element.removeAttribute(attribute.name); + if ((name === "href" || name === "src") && !isSafeUrl(attribute.value)) element.removeAttribute(attribute.name); + } + if (element.tagName === "A") { + element.setAttribute("target", "_blank"); + element.setAttribute("rel", "noreferrer noopener"); + } + }); + return template.innerHTML; +} + +function isSafeUrl(url: string): boolean { + if (url.startsWith("#") || url.startsWith("/")) return true; + try { + return ["http:", "https:", "mailto:"].includes(new URL(url).protocol); + } catch { + return false; + } +} diff --git a/src/client/src/sessionSocket.ts b/src/client/src/sessionSocket.ts index 083310f..720014d 100644 --- a/src/client/src/sessionSocket.ts +++ b/src/client/src/sessionSocket.ts @@ -1,9 +1,10 @@ -import { sessionEvents } from "./api"; +import { sessionEvents, type SessionStatus } from "./api"; export type SessionUiEvent = | { type: "assistant.delta"; text: string } | { type: "tool.start"; toolName: string } | { type: "tool.end"; toolName: string; isError: boolean } + | { type: "status.update"; status: SessionStatus } | { type: "session.error"; message: string }; export class SessionSocket { @@ -25,5 +26,5 @@ export class SessionSocket { } function isSessionUiEvent(event: any): event is SessionUiEvent { - return ["assistant.delta", "tool.start", "tool.end", "session.error"].includes(event?.type); + return ["assistant.delta", "tool.start", "tool.end", "status.update", "session.error"].includes(event?.type); } diff --git a/src/client/src/utils/format.ts b/src/client/src/utils/format.ts new file mode 100644 index 0000000..9c769e0 --- /dev/null +++ b/src/client/src/utils/format.ts @@ -0,0 +1,14 @@ +export function formatTokenCount(count: number): string { + if (!Number.isFinite(count)) return "0"; + if (count < 1000) return Math.round(count).toString(); + if (count < 10_000) return `${(count / 1000).toFixed(1)}k`; + if (count < 1_000_000) return `${Math.round(count / 1000)}k`; + if (count < 10_000_000) return `${(count / 1_000_000).toFixed(1)}M`; + return `${Math.round(count / 1_000_000)}M`; +} + +export function formatCost(cost: number): string { + if (!Number.isFinite(cost) || cost === 0) return "$0"; + if (cost < 0.01) return `$${cost.toFixed(4)}`; + return `$${cost.toFixed(2)}`; +} diff --git a/src/server/index.ts b/src/server/index.ts index ef2dd5e..4837d02 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -8,6 +8,7 @@ import { ProjectService } from "./projects/projectService.js"; import { WorkspaceService } from "./workspaces/workspaceService.js"; import { SessionEventHub } from "./realtime/sessionEventHub.js"; import { PiSessionService } from "./sessions/piSessionService.js"; +import { listFileSuggestions } from "./workspaces/fileSuggestions.js"; const app = Fastify({ logger: true }); await app.register(fastifyWebsocket); @@ -57,6 +58,22 @@ app.get<{ Params: { sessionId: string } }>("/api/sessions/:sessionId/messages", } }); +app.get<{ Params: { sessionId: string } }>("/api/sessions/:sessionId/status", async (request, reply) => { + try { + return await sessions.status(request.params.sessionId); + } catch (error) { + return reply.code(404).send({ error: error instanceof Error ? error.message : String(error) }); + } +}); + +app.get<{ Params: { sessionId: string } }>("/api/sessions/:sessionId/commands", async (request, reply) => { + try { + return await sessions.commands(request.params.sessionId); + } catch (error) { + return reply.code(404).send({ error: error instanceof Error ? error.message : String(error) }); + } +}); + app.post<{ Params: { sessionId: string }; Body: { text: string } }>("/api/sessions/:sessionId/prompt", async (request, reply) => { try { await sessions.prompt(request.params.sessionId, request.body.text); @@ -80,6 +97,15 @@ app.get<{ Params: { sessionId: string } }>("/api/sessions/:sessionId/events", { eventHub.add(request.params.sessionId, socket); }); +app.get<{ Querystring: { cwd?: string; q?: string; kind?: "tracked" | "untracked" | "other" } }>("/api/files", async (request, reply) => { + if (!request.query.cwd) return reply.code(400).send({ error: "cwd query parameter is required" }); + try { + return await listFileSuggestions(request.query.cwd, request.query.q ?? "", request.query.kind); + } catch (error) { + return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) }); + } +}); + const clientDist = join(process.cwd(), "dist", "client"); if (existsSync(clientDist)) { await app.register(fastifyStatic, { root: clientDist }); diff --git a/src/server/sessions/piSessionService.ts b/src/server/sessions/piSessionService.ts index 950a23a..5395389 100644 --- a/src/server/sessions/piSessionService.ts +++ b/src/server/sessions/piSessionService.ts @@ -5,7 +5,7 @@ import { SessionManager, type AgentSession, } from "@mariozechner/pi-coding-agent"; -import type { ClientSession } from "../types.js"; +import type { ClientCommand, ClientSession, ClientSessionStatus } from "../types.js"; import type { SessionEventHub } from "../realtime/sessionEventHub.js"; interface ActiveSession { @@ -13,6 +13,30 @@ interface ActiveSession { unsubscribe: () => void; } +const BUILTIN_COMMANDS: ClientCommand[] = [ + { name: "settings", description: "Open settings menu", source: "builtin" }, + { name: "model", description: "Select model", source: "builtin" }, + { name: "scoped-models", description: "Enable/disable models for cycling", source: "builtin" }, + { name: "export", description: "Export session", source: "builtin" }, + { name: "import", description: "Import and resume a session from JSONL", source: "builtin" }, + { name: "share", description: "Share session as a secret GitHub gist", source: "builtin" }, + { name: "copy", description: "Copy last agent message", source: "builtin" }, + { name: "name", description: "Set session display name", source: "builtin" }, + { name: "session", description: "Show session info and stats", source: "builtin" }, + { name: "changelog", description: "Show changelog entries", source: "builtin" }, + { name: "hotkeys", description: "Show keyboard shortcuts", source: "builtin" }, + { name: "fork", description: "Create a new fork from a previous user message", source: "builtin" }, + { name: "clone", description: "Duplicate current session at current position", source: "builtin" }, + { name: "tree", description: "Navigate session tree", source: "builtin" }, + { name: "login", description: "Configure provider authentication", source: "builtin" }, + { name: "logout", description: "Remove provider authentication", source: "builtin" }, + { name: "new", description: "Start a new session", source: "builtin" }, + { name: "compact", description: "Manually compact session context", source: "builtin" }, + { name: "resume", description: "Resume a different session", source: "builtin" }, + { name: "reload", description: "Reload keybindings, extensions, skills, prompts, and themes", source: "builtin" }, + { name: "quit", description: "Quit pi", source: "builtin" }, +]; + export class PiSessionService { private readonly active = new Map(); private readonly authStorage = AuthStorage.create(); @@ -52,6 +76,25 @@ export class PiSessionService { return session.messages; } + async status(sessionId: string): Promise { + return this.statusFromSession(await this.getOrOpen(sessionId)); + } + + async commands(sessionId: string): Promise { + const session = await this.getOrOpen(sessionId); + const commands: ClientCommand[] = [...BUILTIN_COMMANDS]; + for (const command of session.extensionRunner.getRegisteredCommands()) { + commands.push({ name: command.invocationName, description: command.description, source: "extension" }); + } + for (const template of session.promptTemplates) { + commands.push({ name: template.name, description: template.description, source: "prompt" }); + } + for (const skill of session.resourceLoader.getSkills().skills) { + commands.push({ name: `skill:${skill.name}`, description: skill.description, source: "skill" }); + } + return commands.sort((a, b) => a.name.localeCompare(b.name)); + } + async prompt(sessionId: string, text: string): Promise { const session = await this.getOrOpen(sessionId); void session.prompt(text).catch((error) => { @@ -91,12 +134,38 @@ export class PiSessionService { const unsubscribe = session.subscribe((event) => { this.events.publish(session.sessionId, toClientEvent(event)); + this.events.publish(session.sessionId, { type: "status.update", status: this.statusFromSession(session) }); }); const active = { session, unsubscribe }; this.active.set(session.sessionId, active); + this.events.publish(session.sessionId, { type: "status.update", status: this.statusFromSession(session) }); return active; } + + private statusFromSession(session: AgentSession): ClientSessionStatus { + const stats = session.getSessionStats(); + return { + sessionId: session.sessionId, + model: session.model + ? { + provider: session.model.provider, + id: session.model.id, + name: (session.model as any).name, + contextWindow: session.model.contextWindow, + reasoning: (session.model as any).reasoning, + } + : undefined, + thinkingLevel: session.thinkingLevel, + isStreaming: session.isStreaming, + isCompacting: session.isCompacting, + isBashRunning: session.isBashRunning, + pendingMessageCount: session.pendingMessageCount, + tokens: stats.tokens, + cost: stats.cost, + contextUsage: session.getContextUsage(), + }; + } } function toClientEvent(event: any): unknown { diff --git a/src/server/types.ts b/src/server/types.ts index 62cf163..1cc1f85 100644 --- a/src/server/types.ts +++ b/src/server/types.ts @@ -25,3 +25,27 @@ export interface ClientSession { messageCount: number; firstMessage: string; } + +export interface ClientSessionStatus { + sessionId: string; + model?: { provider?: string; id?: string; name?: string; contextWindow?: number; reasoning?: unknown }; + thinkingLevel?: string; + isStreaming: boolean; + isCompacting: boolean; + isBashRunning: boolean; + pendingMessageCount: number; + tokens: { input: number; output: number; cacheRead: number; cacheWrite: number; total: number }; + cost: number; + contextUsage?: { tokens: number | null; contextWindow: number; percent: number | null }; +} + +export interface ClientCommand { + name: string; + description?: string; + source: "extension" | "prompt" | "skill" | "builtin"; +} + +export interface ClientFileSuggestion { + path: string; + kind: "tracked" | "untracked" | "other"; +} diff --git a/src/server/workspaces/fileSuggestions.ts b/src/server/workspaces/fileSuggestions.ts new file mode 100644 index 0000000..491971c --- /dev/null +++ b/src/server/workspaces/fileSuggestions.ts @@ -0,0 +1,65 @@ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import type { ClientFileSuggestion } from "../types.js"; + +const execFileAsync = promisify(execFile); + +export async function listFileSuggestions(cwd: string, query = "", kind?: ClientFileSuggestion["kind"]): Promise { + const normalizedQuery = query.replace(/^@/, "").toLowerCase(); + const files = await listGitFiles(cwd).catch(() => listPlainFiles(cwd)); + return files + .filter((file) => !kind || file.kind === kind) + .filter((file) => !normalizedQuery || file.path.toLowerCase().includes(normalizedQuery)) + .sort((a, b) => Number(!a.path.endsWith("/")) - Number(!b.path.endsWith("/")) || a.path.localeCompare(b.path)) + .slice(0, 80); +} + +async function listGitFiles(cwd: string): Promise { + const [tracked, untracked] = await Promise.all([ + git(cwd, ["ls-files"]), + git(cwd, ["ls-files", "--others", "--exclude-standard"]), + ]); + return [ + ...withDirectories(lines(tracked), "tracked"), + ...withDirectories(lines(untracked), "untracked"), + ]; +} + +async function listPlainFiles(cwd: string): Promise { + const { stdout } = await execFileAsync("rg", ["--files"], { cwd, maxBuffer: 1024 * 1024 * 8 }); + return withDirectories(lines(stdout), "other"); +} + +async function git(cwd: string, args: string[]): Promise { + const { stdout } = await execFileAsync("git", args, { cwd, maxBuffer: 1024 * 1024 * 8 }); + return stdout; +} + +function lines(text: string): string[] { + return text.split("\n").map((line) => line.trim()).filter(Boolean); +} + +function withDirectories(paths: string[], kind: ClientFileSuggestion["kind"]): ClientFileSuggestion[] { + const seen = new Set(); + const suggestions: ClientFileSuggestion[] = []; + for (const path of paths) { + for (const directory of parentDirectories(path)) add(`${directory}/`); + add(path); + } + return suggestions; + + function add(path: string) { + if (seen.has(path)) return; + seen.add(path); + suggestions.push({ path, kind }); + } +} + +function parentDirectories(path: string): string[] { + const parts = path.split("/").filter(Boolean); + const directories: string[] = []; + for (let index = 1; index < parts.length; index++) { + directories.push(parts.slice(0, index).join("/")); + } + return directories; +}