Add model and thinking controls

This commit is contained in:
Federico Jaramillo Martinez
2026-05-10 20:53:30 +02:00
parent 95f68568ab
commit 6a201cab02
14 changed files with 359 additions and 49 deletions
+1 -1
View File
@@ -1,3 +1,3 @@
export { api, filesApi, gitApi, projectsApi, sessionsApi, terminalsApi, workspacesApi } from "./api/clients";
export { globalSessionEvents, sessionEvents, terminalSocket } from "./api/sockets";
export type { CommandOption, CommandResult, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, MessagePage, Project, QueuedSessionMessage, SessionActivity, SessionInfo, SessionStatus, SlashCommand, SessionUiEvent, TerminalInfo, Workspace } from "../../shared/apiTypes";
export type { CommandOption, CommandResult, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, MessagePage, ModelSelectionResponse, Project, QueuedSessionMessage, SessionActivity, SessionInfo, SessionModel, SessionStatus, SlashCommand, SessionUiEvent, TerminalInfo, ThinkingLevel, ThinkingLevelsResponse, Workspace } from "../../shared/apiTypes";
+8
View File
@@ -14,6 +14,7 @@ import {
parseGitDiffResponse,
parseGitStatusResponse,
parseMessagePage,
parseModelSelectionResponse,
parseProject,
parseRestored,
parseSessionInfo,
@@ -21,6 +22,7 @@ import {
parseSlashCommand,
parseStopped,
parseTerminalInfo,
parseThinkingLevelsResponse,
parseWorkspace,
} from "./parsers";
import { gitDiffUrl, messageUrl } from "./urls";
@@ -43,6 +45,12 @@ export const sessionsApi = {
startSession: (cwd: string) => request("/api/sessions", parseSessionInfo, { method: "POST", body: JSON.stringify({ cwd }) }),
messages: (sessionId: string, options?: { limit?: number; before?: number }) => request(messageUrl(sessionId, options), parseMessagePage),
status: (sessionId: string) => request(`/api/sessions/${sessionId}/status`, parseSessionStatus),
models: (sessionId: string) => request(`/api/sessions/${sessionId}/models`, parseModelSelectionResponse),
setModel: (sessionId: string, provider: string, modelId: string) => request(`/api/sessions/${sessionId}/model`, parseSessionStatus, { method: "POST", body: JSON.stringify({ provider, modelId }) }),
cycleModel: (sessionId: string, direction: "forward" | "backward") => request(`/api/sessions/${sessionId}/model/cycle`, parseSessionStatus, { method: "POST", body: JSON.stringify({ direction }) }),
thinkingLevels: (sessionId: string) => request(`/api/sessions/${sessionId}/thinking-levels`, parseThinkingLevelsResponse),
setThinkingLevel: (sessionId: string, level: "off" | "minimal" | "low" | "medium" | "high" | "xhigh") => request(`/api/sessions/${sessionId}/thinking-level`, parseSessionStatus, { method: "POST", body: JSON.stringify({ level }) }),
cycleThinkingLevel: (sessionId: string) => request(`/api/sessions/${sessionId}/thinking-level/cycle`, parseSessionStatus, { method: "POST" }),
commands: (sessionId: string) => request(`/api/sessions/${sessionId}/commands`, arrayOf(parseSlashCommand)),
prompt: (sessionId: string, text: string, streamingBehavior?: "steer" | "followUp") => request(`/api/sessions/${sessionId}/prompt`, parseAccepted, { method: "POST", body: JSON.stringify(streamingBehavior === undefined ? { text } : { text, streamingBehavior }) }),
shell: (sessionId: string, text: string) => request(`/api/sessions/${sessionId}/shell`, parseAccepted, { method: "POST", body: JSON.stringify({ text }) }),
+21 -2
View File
@@ -1,4 +1,4 @@
import type { CommandOption, CommandResult, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, MessagePage, Project, QueuedSessionMessage, SessionInfo, SessionStatus, SlashCommand, TerminalInfo, Workspace } from "../../../shared/apiTypes";
import type { CommandOption, CommandResult, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, MessagePage, ModelSelectionResponse, Project, QueuedSessionMessage, SessionInfo, SessionModel, SessionStatus, SlashCommand, TerminalInfo, ThinkingLevel, ThinkingLevelsResponse, Workspace } from "../../../shared/apiTypes";
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
@@ -127,10 +127,29 @@ function parseTokens(value: unknown): SessionStatus["tokens"] {
};
}
function parseSessionModel(value: unknown): SessionModel {
const record = requireRecord(value);
return { ...optionalField("provider", optionalString(record, "provider")), ...optionalField("id", optionalString(record, "id")), ...optionalField("name", optionalString(record, "name")), ...optionalField("contextWindow", optionalNumber(record, "contextWindow")), ...optionalField("reasoning", record["reasoning"]) };
}
function optionalModel(value: unknown): Pick<SessionStatus, "model"> | object {
if (value === undefined) return {};
return { model: parseSessionModel(value) };
}
export function parseModelSelectionResponse(value: unknown): ModelSelectionResponse {
const record = requireRecord(value);
return { model: { ...optionalField("provider", optionalString(record, "provider")), ...optionalField("id", optionalString(record, "id")), ...optionalField("name", optionalString(record, "name")), ...optionalField("contextWindow", optionalNumber(record, "contextWindow")), ...optionalField("reasoning", record["reasoning"]) } };
return { models: arrayOf(parseSessionModel)(record["models"]) };
}
function parseThinkingLevel(value: unknown): ThinkingLevel {
if (value !== "off" && value !== "minimal" && value !== "low" && value !== "medium" && value !== "high" && value !== "xhigh") throw new Error("Invalid thinking level");
return value;
}
export function parseThinkingLevelsResponse(value: unknown): ThinkingLevelsResponse {
const record = requireRecord(value);
return { levels: arrayOf(parseThinkingLevel)(record["levels"]) };
}
function optionalContextUsage(value: unknown): Pick<SessionStatus, "contextUsage"> | object {
+5 -1
View File
@@ -1,4 +1,4 @@
import type { CommandResult, FileContentResponse, FileTreeEntry, GitDiffResponse, GitStatusResponse, Project, SessionActivity, SessionInfo, SessionStatus, Workspace } from "./api";
import type { CommandOption, CommandResult, FileContentResponse, FileTreeEntry, GitDiffResponse, GitStatusResponse, Project, SessionActivity, SessionInfo, SessionStatus, Workspace } from "./api";
import type { ChatLine } from "./components/shared";
import type { QualifiedContributionId } from "./plugins/types";
@@ -19,6 +19,8 @@ export interface AppState {
sessionStatuses: Record<string, SessionStatus>;
sessionActivities: Record<string, SessionActivity>;
commandDialog: Extract<CommandResult, { type: "select" }> | undefined;
modelDialog: { title: string; options: CommandOption[]; selectedValue?: string } | undefined;
thinkingDialog: { title: string; options: CommandOption[]; selectedValue?: string } | undefined;
actionPaletteOpen: boolean;
projectDialogOpen: boolean;
workspaceTool: QualifiedContributionId;
@@ -54,6 +56,8 @@ export function initialAppState(): AppState {
sessionStatuses: {},
sessionActivities: {},
commandDialog: undefined,
modelDialog: undefined,
thinkingDialog: undefined,
actionPaletteOpen: false,
projectDialogOpen: false,
workspaceTool: "core:workspace.files",
+32 -5
View File
@@ -7,12 +7,16 @@ import { commandPickerStyles } from "./shared";
@customElement("command-picker")
export class CommandPicker extends LitElement {
@property() override title = "Select";
@property({ type: Boolean }) searchable = false;
@property({ attribute: false }) options: CommandOption[] = [];
@property({ attribute: false }) selectedValue?: string;
@property({ attribute: false }) onPick?: (value: string) => void;
@property({ attribute: false }) onCancel?: () => void;
@state() private selectedIndex = 0;
@state() private query = "";
override render() {
const options = this.filteredOptions();
return html`
<div class="backdrop" @mousedown=${() => this.onCancel?.()}>
<section @mousedown=${(event: MouseEvent) => { event.stopPropagation(); }}>
@@ -20,13 +24,15 @@ export class CommandPicker extends LitElement {
<strong>${this.title}</strong>
<button @click=${() => this.onCancel?.()}>×</button>
</header>
${this.searchable ? html`<input placeholder="Search" .value=${this.query} @input=${(event: Event) => { this.handleSearchInput(event); }} @keydown=${(event: KeyboardEvent) => { this.handleKeyDown(event); }}>` : null}
<div class="options" @keydown=${(event: KeyboardEvent) => { this.handleKeyDown(event); }} tabindex="0">
${this.options.map((option, index) => html`
${options.map((option, index) => html`
<button class=${index === this.selectedIndex ? "selected" : ""} ${scrollWhenSelected(index === this.selectedIndex, option.value)} @click=${() => this.onPick?.(option.value)}>
<span>${option.label}</span>
${option.description !== undefined && option.description !== "" ? html`<small>${option.description}</small>` : null}
</button>
`)}
${options.length === 0 ? html`<div class="empty">No matching options</div>` : null}
</div>
</section>
</div>
@@ -34,22 +40,43 @@ export class CommandPicker extends LitElement {
}
override firstUpdated() {
this.renderRoot.querySelector<HTMLElement>(".options")?.focus();
this.selectInitialValue();
this.renderRoot.querySelector<HTMLElement>(this.searchable ? "input" : ".options")?.focus();
}
private selectInitialValue(): void {
if (this.selectedValue === undefined) return;
const index = this.filteredOptions().findIndex((option) => option.value === this.selectedValue);
if (index >= 0) this.selectedIndex = index;
}
private handleSearchInput(event: Event): void {
if (event.target instanceof HTMLInputElement) {
this.query = event.target.value;
this.selectedIndex = 0;
}
}
private filteredOptions(): CommandOption[] {
const query = this.query.trim().toLowerCase();
if (query === "") return this.options;
return this.options.filter((option) => `${option.label} ${option.description ?? ""} ${option.value}`.toLowerCase().includes(query));
}
private handleKeyDown(event: KeyboardEvent) {
const options = this.filteredOptions();
if (event.key === "Escape") {
event.preventDefault();
this.onCancel?.();
} else if (event.key === "ArrowDown") {
event.preventDefault();
if (this.options.length > 0) this.selectedIndex = (this.selectedIndex + 1) % this.options.length;
if (options.length > 0) this.selectedIndex = (this.selectedIndex + 1) % options.length;
} else if (event.key === "ArrowUp") {
event.preventDefault();
if (this.options.length > 0) this.selectedIndex = (this.selectedIndex - 1 + this.options.length) % this.options.length;
if (options.length > 0) this.selectedIndex = (this.selectedIndex - 1 + options.length) % options.length;
} else if (event.key === "Enter") {
event.preventDefault();
const option = this.options[this.selectedIndex];
const option = options[this.selectedIndex];
if (option) this.onPick?.(option.value);
}
}
+61 -2
View File
@@ -1,6 +1,6 @@
import { LitElement, html } from "lit";
import { customElement, query, state } from "lit/decorators.js";
import type { Project, SessionInfo, Workspace } from "../api";
import type { Project, SessionInfo, ThinkingLevel, Workspace } from "../api";
import type { AppAction } from "../actions";
import { initialAppState, type AppState } from "../appState";
import { FileExplorerController } from "../controllers/fileExplorerController";
@@ -246,6 +246,48 @@ export class PiWebApp extends LitElement {
if (action !== undefined) void action.run();
}
private async openModelDialog() {
const models = await this.sessions.listModels();
const currentProvider = this.state.status?.model?.provider;
const currentId = this.state.status?.model?.id;
this.setState({
modelDialog: {
title: "Select Model",
...(currentProvider !== undefined && currentId !== undefined ? { selectedValue: `${currentProvider}/${currentId}` } : {}),
options: models.map((model) => {
const provider = model.provider ?? "";
const id = model.id ?? "";
const isCurrent = provider === currentProvider && id === currentId;
return { value: `${provider}/${id}`, label: `${id}${isCurrent ? " ✓ current" : ""}`, description: provider };
}),
},
});
}
private async pickModel(value: string) {
this.setState({ modelDialog: undefined });
const slash = value.indexOf("/");
if (slash <= 0) return;
await this.sessions.setModel(value.slice(0, slash), value.slice(slash + 1));
}
private async openThinkingDialog() {
const levels = await this.sessions.listThinkingLevels();
const current = this.state.status?.thinkingLevel ?? "off";
this.setState({
thinkingDialog: {
title: "Select Thinking Level",
selectedValue: current,
options: levels.map((level) => ({ value: level, label: `${level}${level === current ? " ✓ current" : ""}`, description: thinkingDescription(level) })),
},
});
}
private async pickThinking(value: string) {
this.setState({ thinkingDialog: undefined });
if (isThinkingLevel(value)) await this.sessions.setThinkingLevel(value);
}
override render() {
const state = this.state;
return html`
@@ -264,8 +306,10 @@ export class PiWebApp extends LitElement {
${state.selectedSession ? html`
<chat-view .sessionId=${state.selectedSession.id} .messages=${state.messages} .messageStart=${state.messagePageStart} .messageTotal=${state.messagePageTotal} .hasMore=${state.messagePageStart > 0} .loadingMore=${state.isLoadingEarlierMessages} .isReceivingPartialStream=${state.isReceivingPartialStream} .isCompacting=${state.status?.isCompacting === true} .pendingMessageCount=${state.status?.pendingMessageCount ?? 0} .status=${state.status} .activity=${state.activity} .onLoadMore=${() => this.withChatPrependTransition(() => this.sessions.loadEarlierMessages())}></chat-view>
<prompt-editor .sessionId=${state.selectedSession.id} .cwd=${state.selectedWorkspace?.path} .disabled=${state.selectedSession.archived === true} .canSteer=${state.status?.isStreaming === true} .isCompacting=${state.status?.isCompacting === true} .canStop=${state.status?.isStreaming === true || state.status?.isBashRunning === true || state.status?.isCompacting === true || (state.status?.pendingMessageCount ?? 0) > 0} .onSend=${(text: string, streamingBehavior?: "steer" | "followUp") => this.sessions.send(text, streamingBehavior)} .onStop=${() => this.sessions.stopActiveWork()}></prompt-editor>
<status-bar .status=${state.status} .workspace=${state.selectedWorkspace}></status-bar>
<status-bar .status=${state.status} .workspace=${state.selectedWorkspace} .onSelectModel=${() => { void this.openModelDialog(); }} .onCycleModel=${(direction: "forward" | "backward") => { void this.sessions.cycleModel(direction); }} .onSelectThinking=${() => { void this.openThinkingDialog(); }} .onCycleThinking=${() => { void this.sessions.cycleThinkingLevel(); }}></status-bar>
${state.commandDialog !== undefined ? 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}
${state.modelDialog !== undefined ? html`<command-picker title=${state.modelDialog.title} .searchable=${true} .options=${state.modelDialog.options} .selectedValue=${state.modelDialog.selectedValue} .onPick=${(value: string) => { void this.pickModel(value); }} .onCancel=${() => { this.setState({ modelDialog: undefined }); }}></command-picker>` : null}
${state.thinkingDialog !== undefined ? html`<command-picker title=${state.thinkingDialog.title} .options=${state.thinkingDialog.options} .selectedValue=${state.thinkingDialog.selectedValue} .onPick=${(value: string) => { void this.pickThinking(value); }} .onCancel=${() => { this.setState({ thinkingDialog: undefined }); }}></command-picker>` : null}
` : html`<div class="empty">Select or start a session.</div>`}
<div class="mobile-panel">${this.renderWorkspacePanel(true)}</div>
</main>
@@ -292,3 +336,18 @@ function isActive(status: AppState["status"]): boolean {
function nextFrame(): Promise<void> {
return new Promise((resolve) => requestAnimationFrame(() => { resolve(); }));
}
function isThinkingLevel(value: string): value is ThinkingLevel {
return value === "off" || value === "minimal" || value === "low" || value === "medium" || value === "high" || value === "xhigh";
}
function thinkingDescription(level: ThinkingLevel): string {
switch (level) {
case "off": return "No reasoning";
case "minimal": return "Very brief reasoning (~1k tokens)";
case "low": return "Light reasoning (~2k tokens)";
case "medium": return "Moderate reasoning (~8k tokens)";
case "high": return "Deep reasoning (~16k tokens)";
case "xhigh": return "Maximum reasoning (~32k tokens)";
}
}
+13 -2
View File
@@ -8,6 +8,10 @@ import { statusBarStyles } from "./shared";
export class StatusBar extends LitElement {
@property({ attribute: false }) status?: SessionStatus;
@property({ attribute: false }) workspace?: Workspace;
@property({ attribute: false }) onSelectModel?: () => void;
@property({ attribute: false }) onCycleModel?: (direction: "forward" | "backward") => void;
@property({ attribute: false }) onSelectThinking?: () => void;
@property({ attribute: false }) onCycleThinking?: () => void;
override render() {
const status = this.status;
@@ -24,8 +28,15 @@ export class StatusBar extends LitElement {
return html`
<div class="bar">
<span title=${this.workspace?.path ?? ""}>${this.workspace?.label ?? "workspace"}</span>
<span>${provider}${model}</span>
<span>thinking ${status.thinkingLevel ?? "off"}</span>
<span class="control-group">
<button title="Previous model" @click=${() => this.onCycleModel?.("backward")}></button>
<button title="Select model" @click=${() => this.onSelectModel?.()}>${provider}${model}</button>
<button title="Next model" @click=${() => this.onCycleModel?.("forward")}></button>
</span>
<span class="control-group">
<button title="Cycle thinking level" @click=${() => this.onCycleThinking?.()}>thinking ${status.thinkingLevel ?? "off"}</button>
<button title="Select thinking level" @click=${() => this.onSelectThinking?.()}>⌄</button>
</span>
<span>↑${formatTokenCount(tokens.input)}</span>
<span>↓${formatTokenCount(tokens.output)}</span>
<span>${contextText}</span>
+6
View File
@@ -224,6 +224,9 @@ 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-top: 1px solid #30363d; background: #0d1117; white-space: nowrap; overflow: hidden; }
span { overflow: hidden; text-overflow: ellipsis; }
button { border: 0; border-radius: 4px; background: transparent; color: inherit; padding: 1px 3px; font: inherit; cursor: pointer; }
button:hover, button:focus { background: #21262d; color: #e6edf3; }
.control-group { display: inline-flex; align-items: center; gap: 2px; flex: 0 1 auto; min-width: 0; }
.bar > span:first-child { flex: 1 1 auto; min-width: 80px; }
.activity { display: inline-flex; align-items: center; gap: 6px; color: #8b949e; }
.activity.active { color: #3fb950; }
@@ -251,9 +254,12 @@ export const commandPickerStyles = css`
.options { min-height: 0; overflow: auto; outline: none; }
button { border: 0; background: transparent; color: #e6edf3; cursor: pointer; }
header button { font-size: 20px; color: #8b949e; }
input { margin: 10px 12px; border: 1px solid #30363d; border-radius: 8px; background: #0d1117; color: #e6edf3; font: 14px system-ui, sans-serif; padding: 8px 10px; outline: none; }
input:focus { border-color: #58a6ff; }
.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; }
.empty { padding: 24px; color: #8b949e; text-align: center; }
`;
export const actionPaletteStyles = css`
@@ -1,4 +1,4 @@
import { api, type CommandResult, type SessionActivity, type SessionInfo, type SessionStatus } from "../api";
import { api, type CommandResult, type SessionActivity, type SessionInfo, type SessionStatus, type ThinkingLevel } from "../api";
const MESSAGE_PAGE_SIZE = 100;
import { normalizeMessages, textMessage } from "../chatMessages";
@@ -205,6 +205,68 @@ export class SessionController {
}
}
async listModels() {
const session = this.getState().selectedSession;
if (!session || session.archived === true) return [];
try {
return (await api.models(session.id)).models;
} catch (error) {
this.setState({ error: String(error) });
return [];
}
}
async setModel(provider: string, modelId: string) {
const session = this.getState().selectedSession;
if (!session || session.archived === true) return;
try {
this.applyStatus(await api.setModel(session.id, provider, modelId));
} catch (error) {
this.setState({ error: String(error) });
}
}
async cycleModel(direction: "forward" | "backward") {
const session = this.getState().selectedSession;
if (!session || session.archived === true) return;
try {
this.applyStatus(await api.cycleModel(session.id, direction));
} catch (error) {
this.setState({ error: String(error) });
}
}
async listThinkingLevels() {
const session = this.getState().selectedSession;
if (!session || session.archived === true) return [];
try {
return (await api.thinkingLevels(session.id)).levels;
} catch (error) {
this.setState({ error: String(error) });
return [];
}
}
async setThinkingLevel(level: ThinkingLevel) {
const session = this.getState().selectedSession;
if (!session || session.archived === true) return;
try {
this.applyStatus(await api.setThinkingLevel(session.id, level));
} catch (error) {
this.setState({ error: String(error) });
}
}
async cycleThinkingLevel() {
const session = this.getState().selectedSession;
if (!session || session.archived === true) return;
try {
this.applyStatus(await api.cycleThinkingLevel(session.id));
} catch (error) {
this.setState({ error: String(error) });
}
}
async stopActiveWork() {
const session = this.getState().selectedSession;
if (!session) return;
+3 -15
View File
@@ -18,21 +18,6 @@ export function registerSessionProxyRoutes(app: FastifyInstance, daemon = new Se
app.get("/api/sessiond/health", (_request, reply) => proxy({ method: "GET", url: "/api/health" }, reply));
app.get<{ Querystring: { cwd?: string } }>("/api/sessions", (request, reply) => proxy(request, reply));
app.post<{ Body: { cwd: string } }>("/api/sessions", (request, reply) => proxy(request, reply));
app.get<{ Params: { sessionId: string } }>("/api/sessions/:sessionId/messages", (request, reply) => proxy(request, reply));
app.get<{ Params: { sessionId: string } }>("/api/sessions/:sessionId/status", (request, reply) => proxy(request, reply));
app.get<{ Params: { sessionId: string } }>("/api/sessions/:sessionId/commands", (request, reply) => proxy(request, reply));
app.post<{ Params: { sessionId: string }; Body: { text: string; streamingBehavior?: "steer" | "followUp" } }>("/api/sessions/:sessionId/prompt", (request, reply) => proxy(request, reply));
app.post<{ Params: { sessionId: string }; Body: { text: string } }>("/api/sessions/:sessionId/shell", (request, reply) => proxy(request, reply));
app.post<{ Params: { sessionId: string }; Body: { text: string } }>("/api/sessions/:sessionId/commands/run", (request, reply) => proxy(request, reply));
app.post<{ Params: { sessionId: string }; Body: { requestId: string; value: string } }>("/api/sessions/:sessionId/commands/respond", (request, reply) => proxy(request, reply));
app.post<{ Params: { sessionId: string } }>("/api/sessions/:sessionId/abort", (request, reply) => proxy(request, reply));
app.post<{ Params: { sessionId: string } }>("/api/sessions/:sessionId/stop", (request, reply) => proxy(request, reply));
app.post<{ Params: { sessionId: string } }>("/api/sessions/:sessionId/archive", (request, reply) => proxy(request, reply));
app.post<{ Params: { sessionId: string } }>("/api/sessions/:sessionId/restore", (request, reply) => proxy(request, reply));
app.post<{ Params: { sessionId: string } }>("/api/sessions/:sessionId/detach-parent", (request, reply) => proxy(request, reply));
app.get<{ Params: { sessionId: string } }>("/api/sessions/:sessionId/events", { websocket: true }, (socket, request) => {
bridgeSockets(socket, daemon.connectWebSocket(`/sessions/${request.params.sessionId}/events`));
});
@@ -40,6 +25,9 @@ export function registerSessionProxyRoutes(app: FastifyInstance, daemon = new Se
app.get("/api/sessions/events", { websocket: true }, (socket) => {
bridgeSockets(socket, daemon.connectWebSocket("/sessions/events"));
});
app.all("/api/sessions", (request, reply) => proxy(request, reply));
app.all("/api/sessions/*", (request, reply) => proxy(request, reply));
}
function stripApiPrefix(url: string): string {
+77 -19
View File
@@ -10,7 +10,7 @@ import {
type AgentSession,
type CreateAgentSessionRuntimeFactory,
} from "@earendil-works/pi-coding-agent";
import type { ClientCommand, ClientCommandResult, ClientMessagePage, ClientSession, ClientSessionStatus, SessionUiEvent } from "../types.js";
import type { ClientCommand, ClientCommandResult, ClientMessagePage, ClientSession, ClientSessionModel, ClientSessionStatus, ClientThinkingLevel, SessionUiEvent } from "../types.js";
import type { SessionEventHub } from "../realtime/sessionEventHub.js";
import { BUILTIN_COMMANDS } from "./builtinCommands.js";
import { SessionCommandService } from "./sessionCommandService.js";
@@ -151,6 +151,65 @@ export class PiSessionService {
return this.statusFromSession(await this.getOrOpen(sessionId));
}
async availableModels(sessionId: string): Promise<ClientSessionModel[]> {
const session = await this.getOrOpen(sessionId);
session.modelRegistry.refresh();
const models = session.scopedModels.length > 0
? session.scopedModels.map((scoped) => scoped.model)
: session.modelRegistry.getAvailable();
return models.map(modelToClientModel);
}
async setModel(sessionId: string, provider: string, modelId: string): Promise<ClientSessionStatus> {
await this.assertWritable(sessionId);
const session = await this.getOrOpen(sessionId);
session.modelRegistry.refresh();
const candidates = session.scopedModels.length > 0
? session.scopedModels.map((scoped) => scoped.model)
: session.modelRegistry.getAvailable();
const model = candidates.find((candidate) => candidate.provider === provider && candidate.id === modelId)
?? session.modelRegistry.find(provider, modelId);
if (model === undefined) throw new Error(`Model not found: ${provider}/${modelId}`);
await session.setModel(model);
this.publishActivity(session, `model: ${model.id}`, "idle", model.provider);
this.publishStatus(session);
return this.statusFromSession(session);
}
async cycleModel(sessionId: string, direction: "forward" | "backward"): Promise<ClientSessionStatus> {
await this.assertWritable(sessionId);
const session = await this.getOrOpen(sessionId);
const result = await session.cycleModel(direction);
if (result === undefined) throw new Error(session.scopedModels.length > 0 ? "Only one model in scope" : "Only one model available");
this.publishActivity(session, `model: ${result.model.id}`, "idle", result.model.provider);
this.publishStatus(session);
return this.statusFromSession(session);
}
async availableThinkingLevels(sessionId: string): Promise<ClientThinkingLevel[]> {
const session = await this.getOrOpen(sessionId);
return session.getAvailableThinkingLevels();
}
async setThinkingLevel(sessionId: string, level: ClientThinkingLevel): Promise<ClientSessionStatus> {
await this.assertWritable(sessionId);
const session = await this.getOrOpen(sessionId);
session.setThinkingLevel(level);
this.publishActivity(session, `thinking: ${session.thinkingLevel}`, "idle");
this.publishStatus(session);
return this.statusFromSession(session);
}
async cycleThinkingLevel(sessionId: string): Promise<ClientSessionStatus> {
await this.assertWritable(sessionId);
const session = await this.getOrOpen(sessionId);
const level = session.cycleThinkingLevel();
if (level === undefined) throw new Error("Current model does not support thinking");
this.publishActivity(session, `thinking: ${level}`, "idle");
this.publishStatus(session);
return this.statusFromSession(session);
}
async commands(sessionId: string): Promise<ClientCommand[]> {
const session = await this.getOrOpen(sessionId);
const commands: ClientCommand[] = [...BUILTIN_COMMANDS];
@@ -402,19 +461,7 @@ export class PiSessionService {
private statusFromSession(session: AgentSession): ClientSessionStatus {
const stats = session.getSessionStats();
const model = session.model === undefined
? undefined
: (() => {
const name = getString(session.model, "name");
const reasoning = getProperty(session.model, "reasoning");
return {
provider: session.model.provider,
id: session.model.id,
...(name === undefined ? {} : { name }),
contextWindow: session.model.contextWindow,
...(reasoning === undefined ? {} : { reasoning }),
};
})();
const model = session.model === undefined ? undefined : modelToClientModel(session.model);
const contextUsage = session.getContextUsage();
return {
sessionId: session.sessionId,
@@ -432,6 +479,19 @@ export class PiSessionService {
}
}
function modelToClientModel(model: AgentSession["model"]): ClientSessionModel {
if (model === undefined) return {};
const name = getString(model, "name");
const reasoning = getProperty(model, "reasoning");
return {
provider: model.provider,
id: model.id,
...(name === undefined ? {} : { name }),
contextWindow: model.contextWindow,
...(reasoning === undefined ? {} : { reasoning }),
};
}
async function clearParentSession(sessionFile: string): Promise<void> {
const content = await readFile(sessionFile, "utf8");
const newlineIndex = content.indexOf("\n");
@@ -445,8 +505,7 @@ async function clearParentSession(sessionFile: string): Promise<void> {
}
function clearSessionQueue(session: AgentSession): void {
const candidate = session as AgentSession & { clearQueue?: () => unknown };
candidate.clearQueue?.();
session.clearQueue();
}
function hasQueuedMessageText(session: AgentSession, text: string): boolean {
@@ -454,10 +513,9 @@ function hasQueuedMessageText(session: AgentSession, text: string): boolean {
}
function queuedMessagesFromSession(session: AgentSession): { kind: "steer" | "followUp"; text: string }[] {
const candidate = session as AgentSession & { getSteeringMessages?: () => string[]; getFollowUpMessages?: () => string[] };
return [
...(candidate.getSteeringMessages?.() ?? []).map((text) => ({ kind: "steer" as const, text })),
...(candidate.getFollowUpMessages?.() ?? []).map((text) => ({ kind: "followUp" as const, text })),
...session.getSteeringMessages().map((text) => ({ kind: "steer" as const, text })),
...session.getFollowUpMessages().map((text) => ({ kind: "followUp" as const, text })),
];
}
+48
View File
@@ -33,6 +33,54 @@ export function registerSessionRoutes(app: FastifyInstance, sessions: PiSessionS
}
});
app.get<{ Params: { sessionId: string } }>(`${prefix}/sessions/:sessionId/models`, async (request, reply) => {
try {
return { models: await sessions.availableModels(request.params.sessionId) };
} catch (error) {
return reply.code(404).send({ error: error instanceof Error ? error.message : String(error) });
}
});
app.post<{ Params: { sessionId: string }; Body: { provider: string; modelId: string } }>(`${prefix}/sessions/:sessionId/model`, async (request, reply) => {
try {
return await sessions.setModel(request.params.sessionId, request.body.provider, request.body.modelId);
} catch (error) {
return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) });
}
});
app.post<{ Params: { sessionId: string }; Body: { direction?: "forward" | "backward" } }>(`${prefix}/sessions/:sessionId/model/cycle`, async (request, reply) => {
try {
return await sessions.cycleModel(request.params.sessionId, request.body.direction ?? "forward");
} catch (error) {
return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) });
}
});
app.get<{ Params: { sessionId: string } }>(`${prefix}/sessions/:sessionId/thinking-levels`, async (request, reply) => {
try {
return { levels: await sessions.availableThinkingLevels(request.params.sessionId) };
} catch (error) {
return reply.code(404).send({ error: error instanceof Error ? error.message : String(error) });
}
});
app.post<{ Params: { sessionId: string }; Body: { level: "off" | "minimal" | "low" | "medium" | "high" | "xhigh" } }>(`${prefix}/sessions/:sessionId/thinking-level`, async (request, reply) => {
try {
return await sessions.setThinkingLevel(request.params.sessionId, request.body.level);
} catch (error) {
return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) });
}
});
app.post<{ Params: { sessionId: string } }>(`${prefix}/sessions/:sessionId/thinking-level/cycle`, async (request, reply) => {
try {
return await sessions.cycleThinkingLevel(request.params.sessionId);
} catch (error) {
return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) });
}
});
app.get<{ Params: { sessionId: string } }>(`${prefix}/sessions/:sessionId/commands`, async (request, reply) => {
try {
return await sessions.commands(request.params.sessionId);
+2
View File
@@ -4,6 +4,8 @@ export type {
SessionInfo as ClientSession,
MessagePage as ClientMessagePage,
SessionStatus as ClientSessionStatus,
SessionModel as ClientSessionModel,
ThinkingLevel as ClientThinkingLevel,
SlashCommand as ClientCommand,
FileSuggestion as ClientFileSuggestion,
CommandOption as ClientCommandOption,
+19 -1
View File
@@ -43,9 +43,27 @@ export interface QueuedSessionMessage {
text: string;
}
export interface SessionModel {
provider?: string;
id?: string;
name?: string;
contextWindow?: number;
reasoning?: unknown;
}
export type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh";
export interface ModelSelectionResponse {
models: SessionModel[];
}
export interface ThinkingLevelsResponse {
levels: ThinkingLevel[];
}
export interface SessionStatus {
sessionId: string;
model?: { provider?: string; id?: string; name?: string; contextWindow?: number; reasoning?: unknown };
model?: SessionModel;
thinkingLevel?: string;
isStreaming: boolean;
isCompacting: boolean;