Archived
Add web command execution
This commit is contained in:
@@ -50,6 +50,17 @@ export interface FileSuggestion {
|
||||
kind: "tracked" | "untracked" | "other";
|
||||
}
|
||||
|
||||
export interface CommandOption {
|
||||
value: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export type CommandResult =
|
||||
| { type: "done"; message?: string; session?: SessionInfo }
|
||||
| { type: "select"; requestId: string; title: string; options: CommandOption[] }
|
||||
| { type: "unsupported"; message: string };
|
||||
|
||||
async function request<T>(url: string, init?: RequestInit): Promise<T> {
|
||||
const response = await fetch(url, {
|
||||
...init,
|
||||
@@ -73,6 +84,8 @@ export const api = {
|
||||
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 }) }),
|
||||
runCommand: (sessionId: string, text: string) => request<CommandResult>(`/api/sessions/${sessionId}/commands/run`, { method: "POST", body: JSON.stringify({ text }) }),
|
||||
respondToCommand: (sessionId: string, requestId: string, value: string) => request<CommandResult>(`/api/sessions/${sessionId}/commands/respond`, { method: "POST", body: JSON.stringify({ requestId, value }) }),
|
||||
close: (sessionId: string) => request<{ closed: true }>(`/api/sessions/${sessionId}/close`, { method: "POST" }),
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Project, SessionInfo, SessionStatus, Workspace } from "./api";
|
||||
import type { CommandResult, Project, SessionInfo, SessionStatus, Workspace } from "./api";
|
||||
import type { ChatLine } from "./components/shared";
|
||||
|
||||
export interface AppState {
|
||||
@@ -10,6 +10,7 @@ export interface AppState {
|
||||
selectedWorkspace?: Workspace;
|
||||
selectedSession?: SessionInfo;
|
||||
status?: SessionStatus;
|
||||
commandDialog?: Extract<CommandResult, { type: "select" }>;
|
||||
error: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { LitElement, html } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators.js";
|
||||
import type { CommandOption } from "../api";
|
||||
import { commandPickerStyles } from "./shared";
|
||||
|
||||
@customElement("command-picker")
|
||||
export class CommandPicker extends LitElement {
|
||||
@property() title = "Select";
|
||||
@property({ attribute: false }) options: CommandOption[] = [];
|
||||
@property({ attribute: false }) onPick?: (value: string) => void;
|
||||
@property({ attribute: false }) onCancel?: () => void;
|
||||
@state() private selectedIndex = 0;
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div class="backdrop" @mousedown=${() => this.onCancel?.()}>
|
||||
<section @mousedown=${(event: MouseEvent) => event.stopPropagation()}>
|
||||
<header>
|
||||
<strong>${this.title}</strong>
|
||||
<button @click=${() => this.onCancel?.()}>×</button>
|
||||
</header>
|
||||
<div class="options" @keydown=${(event: KeyboardEvent) => this.handleKeyDown(event)} tabindex="0">
|
||||
${this.options.map((option, index) => html`
|
||||
<button class=${index === this.selectedIndex ? "selected" : ""} @click=${() => this.onPick?.(option.value)}>
|
||||
<span>${option.label}</span>
|
||||
${option.description ? html`<small>${option.description}</small>` : null}
|
||||
</button>
|
||||
`)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
firstUpdated() {
|
||||
this.renderRoot.querySelector<HTMLElement>(".options")?.focus();
|
||||
}
|
||||
|
||||
private handleKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
this.onCancel?.();
|
||||
} else if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
this.selectedIndex = (this.selectedIndex + 1) % this.options.length;
|
||||
} else if (event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
this.selectedIndex = (this.selectedIndex - 1 + this.options.length) % this.options.length;
|
||||
} else if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
const option = this.options[this.selectedIndex];
|
||||
if (option) this.onPick?.(option.value);
|
||||
}
|
||||
}
|
||||
|
||||
static styles = commandPickerStyles;
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import "./SessionList";
|
||||
import "./ChatView";
|
||||
import "./PromptEditor";
|
||||
import "./StatusBar";
|
||||
import "./CommandPicker";
|
||||
import { appStyles } from "./shared";
|
||||
|
||||
@customElement("pi-web-poc")
|
||||
@@ -92,6 +93,7 @@ export class PiWebApp extends LitElement {
|
||||
<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>
|
||||
${state.commandDialog ? html`<command-picker .title=${state.commandDialog.title} .options=${state.commandDialog.options} .onPick=${(value: string) => this.sessions.respondToCommand(state.commandDialog!.requestId, value)} .onCancel=${() => this.sessions.cancelCommand()}></command-picker>` : null}
|
||||
` : html`<div class="empty">Select or start a session.</div>`}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -106,6 +106,19 @@ export const autocompleteStyles = css`
|
||||
small { grid-column: 1 / -1; color: #8b949e; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
`;
|
||||
|
||||
export const commandPickerStyles = css`
|
||||
:host { position: fixed; inset: 0; z-index: 10; color: #e6edf3; font: 14px system-ui, sans-serif; }
|
||||
.backdrop { display: grid; place-items: center; width: 100%; height: 100%; background: #0008; }
|
||||
section { width: min(720px, calc(100vw - 40px)); max-height: min(640px, calc(100vh - 40px)); display: flex; flex-direction: column; border: 1px solid #30363d; border-radius: 12px; background: #0d1117; box-shadow: 0 20px 60px #000b; overflow: hidden; }
|
||||
header { display: flex; align-items: center; justify-content: space-between; padding: 12px; border-bottom: 1px solid #30363d; }
|
||||
.options { min-height: 0; overflow: auto; outline: none; }
|
||||
button { border: 0; background: transparent; color: #e6edf3; cursor: pointer; }
|
||||
header button { font-size: 20px; color: #8b949e; }
|
||||
.options button { display: block; width: 100%; padding: 10px 12px; border-bottom: 1px solid #21262d; text-align: left; }
|
||||
.options button.selected, .options button:hover { background: #0d2847; }
|
||||
small { display: block; margin-top: 4px; color: #8b949e; }
|
||||
`;
|
||||
|
||||
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; }
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { api, type SessionInfo } from "../api";
|
||||
import { api, type CommandResult, type SessionInfo } from "../api";
|
||||
import { appendText, normalizeMessages, textMessage } from "../chatMessages";
|
||||
import { SessionSocket, type SessionUiEvent } from "../sessionSocket";
|
||||
import type { GetState, SetState, UpdateUrl } from "./types";
|
||||
@@ -41,6 +41,7 @@ export class SessionController {
|
||||
}
|
||||
|
||||
async send(text: string) {
|
||||
if (text.trim().startsWith("/")) return this.runCommand(text);
|
||||
const session = this.getState().selectedSession;
|
||||
if (!session) return;
|
||||
this.setState({ messages: [...this.getState().messages, textMessage("user", text)] });
|
||||
@@ -51,6 +52,31 @@ export class SessionController {
|
||||
}
|
||||
}
|
||||
|
||||
async runCommand(text: string) {
|
||||
const session = this.getState().selectedSession;
|
||||
if (!session) return;
|
||||
try {
|
||||
this.applyCommandResult(await api.runCommand(session.id, text));
|
||||
} catch (error) {
|
||||
this.setState({ error: String(error) });
|
||||
}
|
||||
}
|
||||
|
||||
async respondToCommand(requestId: string, value: string) {
|
||||
const session = this.getState().selectedSession;
|
||||
if (!session) return;
|
||||
this.setState({ commandDialog: undefined });
|
||||
try {
|
||||
this.applyCommandResult(await api.respondToCommand(session.id, requestId, value));
|
||||
} catch (error) {
|
||||
this.setState({ error: String(error) });
|
||||
}
|
||||
}
|
||||
|
||||
cancelCommand() {
|
||||
this.setState({ commandDialog: undefined });
|
||||
}
|
||||
|
||||
async closeSession() {
|
||||
const session = this.getState().selectedSession;
|
||||
if (!session) return;
|
||||
@@ -64,6 +90,20 @@ export class SessionController {
|
||||
}
|
||||
}
|
||||
|
||||
private applyCommandResult(result: CommandResult) {
|
||||
if (result.type === "select") {
|
||||
this.setState({ commandDialog: result });
|
||||
return;
|
||||
}
|
||||
const message = result.type === "unsupported" ? result.message : result.message;
|
||||
if (message) this.setState({ messages: [...this.getState().messages, textMessage(result.type === "unsupported" ? "system" : "tool", message)] });
|
||||
if (result.type === "done" && result.session) {
|
||||
const sessions = [result.session, ...this.getState().sessions.filter((session) => session.id !== result.session?.id)];
|
||||
this.setState({ sessions });
|
||||
void this.selectSession(result.session);
|
||||
}
|
||||
}
|
||||
|
||||
private applyEvent(event: SessionUiEvent) {
|
||||
const messages = this.getState().messages;
|
||||
if (event.type === "assistant.delta") {
|
||||
|
||||
Reference in New Issue
Block a user