diff --git a/src/client/src/api.ts b/src/client/src/api.ts index cdd2655..2bded1e 100644 --- a/src/client/src/api.ts +++ b/src/client/src/api.ts @@ -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"; diff --git a/src/client/src/api/clients.ts b/src/client/src/api/clients.ts index 53b11d2..085f8a5 100644 --- a/src/client/src/api/clients.ts +++ b/src/client/src/api/clients.ts @@ -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 }) }), diff --git a/src/client/src/api/parsers.ts b/src/client/src/api/parsers.ts index 9745b04..b0819e8 100644 --- a/src/client/src/api/parsers.ts +++ b/src/client/src/api/parsers.ts @@ -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 { 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 | 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 | object { diff --git a/src/client/src/appState.ts b/src/client/src/appState.ts index f196641..dc74f0d 100644 --- a/src/client/src/appState.ts +++ b/src/client/src/appState.ts @@ -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; sessionActivities: Record; commandDialog: Extract | 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", diff --git a/src/client/src/components/CommandPicker.ts b/src/client/src/components/CommandPicker.ts index 66f6546..1e10486 100644 --- a/src/client/src/components/CommandPicker.ts +++ b/src/client/src/components/CommandPicker.ts @@ -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`
this.onCancel?.()}>
{ event.stopPropagation(); }}> @@ -20,13 +24,15 @@ export class CommandPicker extends LitElement { ${this.title} + ${this.searchable ? html` { this.handleSearchInput(event); }} @keydown=${(event: KeyboardEvent) => { this.handleKeyDown(event); }}>` : null}
{ this.handleKeyDown(event); }} tabindex="0"> - ${this.options.map((option, index) => html` + ${options.map((option, index) => html` `)} + ${options.length === 0 ? html`
No matching options
` : null}
@@ -34,22 +40,43 @@ export class CommandPicker extends LitElement { } override firstUpdated() { - this.renderRoot.querySelector(".options")?.focus(); + this.selectInitialValue(); + this.renderRoot.querySelector(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); } } diff --git a/src/client/src/components/PiWebApp.ts b/src/client/src/components/PiWebApp.ts index 205363e..f6e5a2b 100644 --- a/src/client/src/components/PiWebApp.ts +++ b/src/client/src/components/PiWebApp.ts @@ -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` 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())}> 0} .onSend=${(text: string, streamingBehavior?: "steer" | "followUp") => this.sessions.send(text, streamingBehavior)} .onStop=${() => this.sessions.stopActiveWork()}> - + { void this.openModelDialog(); }} .onCycleModel=${(direction: "forward" | "backward") => { void this.sessions.cycleModel(direction); }} .onSelectThinking=${() => { void this.openThinkingDialog(); }} .onCycleThinking=${() => { void this.sessions.cycleThinkingLevel(); }}> ${state.commandDialog !== undefined ? html` this.sessions.respondToCommand(state.commandDialog?.requestId ?? "", value)} .onCancel=${() => { this.sessions.cancelCommand(); }}>` : null} + ${state.modelDialog !== undefined ? html` { void this.pickModel(value); }} .onCancel=${() => { this.setState({ modelDialog: undefined }); }}>` : null} + ${state.thinkingDialog !== undefined ? html` { void this.pickThinking(value); }} .onCancel=${() => { this.setState({ thinkingDialog: undefined }); }}>` : null} ` : html`
Select or start a session.
`}
${this.renderWorkspacePanel(true)}
@@ -292,3 +336,18 @@ function isActive(status: AppState["status"]): boolean { function nextFrame(): Promise { 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)"; + } +} diff --git a/src/client/src/components/StatusBar.ts b/src/client/src/components/StatusBar.ts index c25e36e..220a1cd 100644 --- a/src/client/src/components/StatusBar.ts +++ b/src/client/src/components/StatusBar.ts @@ -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`
${this.workspace?.label ?? "workspace"} - ${provider}${model} - thinking ${status.thinkingLevel ?? "off"} + + + + + + + + + ↑${formatTokenCount(tokens.input)} ↓${formatTokenCount(tokens.output)} ${contextText} diff --git a/src/client/src/components/shared.ts b/src/client/src/components/shared.ts index cc94842..248231e 100644 --- a/src/client/src/components/shared.ts +++ b/src/client/src/components/shared.ts @@ -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` diff --git a/src/client/src/controllers/sessionController.ts b/src/client/src/controllers/sessionController.ts index e8b20ff..6821221 100644 --- a/src/client/src/controllers/sessionController.ts +++ b/src/client/src/controllers/sessionController.ts @@ -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; diff --git a/src/server/sessiond/sessionProxyRoutes.ts b/src/server/sessiond/sessionProxyRoutes.ts index 2d868cf..2742786 100644 --- a/src/server/sessiond/sessionProxyRoutes.ts +++ b/src/server/sessiond/sessionProxyRoutes.ts @@ -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 { diff --git a/src/server/sessions/piSessionService.ts b/src/server/sessions/piSessionService.ts index 006f91a..d0e2e37 100644 --- a/src/server/sessions/piSessionService.ts +++ b/src/server/sessions/piSessionService.ts @@ -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 { + 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 { + 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 { + 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 { + const session = await this.getOrOpen(sessionId); + return session.getAvailableThinkingLevels(); + } + + async setThinkingLevel(sessionId: string, level: ClientThinkingLevel): Promise { + 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 { + 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 { 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 { const content = await readFile(sessionFile, "utf8"); const newlineIndex = content.indexOf("\n"); @@ -445,8 +505,7 @@ async function clearParentSession(sessionFile: string): Promise { } 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 })), ]; } diff --git a/src/server/sessions/sessionRoutes.ts b/src/server/sessions/sessionRoutes.ts index 08fbe0c..8be2c74 100644 --- a/src/server/sessions/sessionRoutes.ts +++ b/src/server/sessions/sessionRoutes.ts @@ -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); diff --git a/src/server/types.ts b/src/server/types.ts index 200720e..ff3f853 100644 --- a/src/server/types.ts +++ b/src/server/types.ts @@ -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, diff --git a/src/shared/apiTypes.ts b/src/shared/apiTypes.ts index f6b13c5..5bfdd83 100644 --- a/src/shared/apiTypes.ts +++ b/src/shared/apiTypes.ts @@ -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;