Archived
Add prompt autocomplete and session status
This commit is contained in:
@@ -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<T>(url: string, init?: RequestInit): Promise<T> {
|
||||
const response = await fetch(url, {
|
||||
...init,
|
||||
@@ -45,6 +69,9 @@ export const api = {
|
||||
sessions: (cwd: string) => request<SessionInfo[]>(`/api/sessions?cwd=${encodeURIComponent(cwd)}`),
|
||||
startSession: (cwd: string) => request<SessionInfo>("/api/sessions", { method: "POST", body: JSON.stringify({ cwd }) }),
|
||||
messages: (sessionId: string) => request<any[]>(`/api/sessions/${sessionId}/messages`),
|
||||
status: (sessionId: string) => request<SessionStatus>(`/api/sessions/${sessionId}/status`),
|
||||
commands: (sessionId: string) => request<SlashCommand[]>(`/api/sessions/${sessionId}/commands`),
|
||||
files: (cwd: string, query: string, kind?: FileSuggestion["kind"]) => request<FileSuggestion[]>(`/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" }),
|
||||
};
|
||||
|
||||
@@ -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: "",
|
||||
};
|
||||
}
|
||||
@@ -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`
|
||||
<div class="menu">
|
||||
${this.items.map((item, index) => html`
|
||||
<button class=${index === this.selectedIndex ? "selected" : ""} @mousedown=${(event: MouseEvent) => { event.preventDefault(); this.onPick?.(item); }}>
|
||||
<strong>${item.insertText}</strong>
|
||||
<span>${item.detail}</span>
|
||||
${item.description ? html`<small>${item.description}</small>` : null}
|
||||
</button>
|
||||
`)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
static styles = autocompleteStyles;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<AppState>) {
|
||||
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`
|
||||
<div class="shell">
|
||||
<aside>
|
||||
<header>
|
||||
<strong>Pi Web POC</strong>
|
||||
<button @click=${this.addProject}>+ Project</button>
|
||||
<button @click=${() => this.projects.addProject()}>+ Project</button>
|
||||
</header>
|
||||
<project-list .projects=${this.projects} .selected=${this.selectedProject} .onSelect=${(project: Project) => this.selectProject(project)}></project-list>
|
||||
<workspace-list .workspaces=${this.workspaces} .selected=${this.selectedWorkspace} .onSelect=${(workspace: Workspace) => this.selectWorkspace(workspace)}></workspace-list>
|
||||
<session-list .sessions=${this.sessions} .selected=${this.selectedSession} .canStart=${!!this.selectedWorkspace} .onStart=${() => this.startSession()} .onSelect=${(session: SessionInfo) => this.selectSession(session)}></session-list>
|
||||
<project-list .projects=${state.projects} .selected=${state.selectedProject} .onSelect=${(project: Project) => this.workspaces.selectProject(project)}></project-list>
|
||||
<workspace-list .workspaces=${state.workspaces} .selected=${state.selectedWorkspace} .onSelect=${(workspace: Workspace) => this.workspaces.selectWorkspace(workspace)}></workspace-list>
|
||||
<session-list .sessions=${state.sessions} .selected=${state.selectedSession} .canStart=${!!state.selectedWorkspace} .onStart=${() => this.sessions.startSession()} .onSelect=${(session: SessionInfo) => this.sessions.selectSession(session)}></session-list>
|
||||
</aside>
|
||||
<main>
|
||||
${this.error ? html`<div class="error">${this.error}</div>` : null}
|
||||
${this.selectedSession ? html`
|
||||
<chat-view .messages=${this.messages}></chat-view>
|
||||
<chat-composer .onSend=${(text: string) => this.send(text)} .onCloseSession=${() => this.closeSession()}></chat-composer>
|
||||
${state.error ? html`<div class="error">${state.error}</div>` : null}
|
||||
${state.selectedSession ? html`
|
||||
<status-bar .status=${state.status} .workspace=${state.selectedWorkspace}></status-bar>
|
||||
<chat-view .messages=${state.messages}></chat-view>
|
||||
<prompt-editor .sessionId=${state.selectedSession.id} .cwd=${state.selectedWorkspace?.path} .onSend=${(text: string) => this.sessions.send(text)} .onCloseSession=${() => this.sessions.closeSession()}></prompt-editor>
|
||||
` : html`<div class="empty">Select or start a session.</div>`}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -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`
|
||||
<footer>
|
||||
<div class="editor-wrap">
|
||||
<textarea
|
||||
.value=${this.draft}
|
||||
?disabled=${this.disabled}
|
||||
@input=${(event: Event) => this.updateDraft((event.target as HTMLTextAreaElement).value)}
|
||||
@keydown=${(event: KeyboardEvent) => this.handleKeyDown(event)}
|
||||
placeholder="Message pi... Use / for commands, @ for files"
|
||||
></textarea>
|
||||
<autocomplete-menu .items=${this.completions} .selectedIndex=${this.selectedIndex} .onPick=${(item: CompletionItem) => this.pick(item)}></autocomplete-menu>
|
||||
</div>
|
||||
<button ?disabled=${this.disabled} @click=${this.send}>Send</button>
|
||||
<button ?disabled=${this.disabled} @click=${() => this.onCloseSession?.()}>Close</button>
|
||||
</footer>
|
||||
`;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -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`<div class="bar muted">No session status yet</div>`;
|
||||
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`
|
||||
<div class="bar">
|
||||
<span title=${this.workspace?.path ?? ""}>${this.workspace?.label ?? "workspace"}</span>
|
||||
<span>${state}</span>
|
||||
<span>${provider}${model}</span>
|
||||
<span>thinking ${status.thinkingLevel ?? "off"}</span>
|
||||
<span>↑${formatTokenCount(tokens.input)}</span>
|
||||
<span>↓${formatTokenCount(tokens.output)}</span>
|
||||
<span>${contextText}</span>
|
||||
<span>${formatCost(status.cost)}</span>
|
||||
${status.pendingMessageCount ? html`<span>${status.pendingMessageCount} queued</span>` : null}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
static styles = statusBarStyles;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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) });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)] });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { AppState } from "../appState";
|
||||
|
||||
export type GetState = () => AppState;
|
||||
export type SetState = (patch: Partial<AppState>) => void;
|
||||
export type UpdateUrl = () => void;
|
||||
|
||||
export interface RouteTarget {
|
||||
workspaceId?: string;
|
||||
sessionId?: string;
|
||||
updateUrl?: boolean;
|
||||
}
|
||||
@@ -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) });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)}`;
|
||||
}
|
||||
@@ -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 });
|
||||
|
||||
@@ -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<string, ActiveSession>();
|
||||
private readonly authStorage = AuthStorage.create();
|
||||
@@ -52,6 +76,25 @@ export class PiSessionService {
|
||||
return session.messages;
|
||||
}
|
||||
|
||||
async status(sessionId: string): Promise<ClientSessionStatus> {
|
||||
return this.statusFromSession(await this.getOrOpen(sessionId));
|
||||
}
|
||||
|
||||
async commands(sessionId: string): Promise<ClientCommand[]> {
|
||||
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<void> {
|
||||
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 {
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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<ClientFileSuggestion[]> {
|
||||
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<ClientFileSuggestion[]> {
|
||||
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<ClientFileSuggestion[]> {
|
||||
const { stdout } = await execFileAsync("rg", ["--files"], { cwd, maxBuffer: 1024 * 1024 * 8 });
|
||||
return withDirectories(lines(stdout), "other");
|
||||
}
|
||||
|
||||
async function git(cwd: string, args: string[]): Promise<string> {
|
||||
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<string>();
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user