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 { api, filesApi, gitApi, projectsApi, sessionsApi, terminalsApi, workspacesApi } from "./api/clients";
export { globalSessionEvents, sessionEvents, terminalSocket } from "./api/sockets"; 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, parseGitDiffResponse,
parseGitStatusResponse, parseGitStatusResponse,
parseMessagePage, parseMessagePage,
parseModelSelectionResponse,
parseProject, parseProject,
parseRestored, parseRestored,
parseSessionInfo, parseSessionInfo,
@@ -21,6 +22,7 @@ import {
parseSlashCommand, parseSlashCommand,
parseStopped, parseStopped,
parseTerminalInfo, parseTerminalInfo,
parseThinkingLevelsResponse,
parseWorkspace, parseWorkspace,
} from "./parsers"; } from "./parsers";
import { gitDiffUrl, messageUrl } from "./urls"; 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 }) }), 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), messages: (sessionId: string, options?: { limit?: number; before?: number }) => request(messageUrl(sessionId, options), parseMessagePage),
status: (sessionId: string) => request(`/api/sessions/${sessionId}/status`, parseSessionStatus), 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)), 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 }) }), 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 }) }), 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> { function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null; 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 { function optionalModel(value: unknown): Pick<SessionStatus, "model"> | object {
if (value === undefined) return {}; if (value === undefined) return {};
return { model: parseSessionModel(value) };
}
export function parseModelSelectionResponse(value: unknown): ModelSelectionResponse {
const record = requireRecord(value); 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 { 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 { ChatLine } from "./components/shared";
import type { QualifiedContributionId } from "./plugins/types"; import type { QualifiedContributionId } from "./plugins/types";
@@ -19,6 +19,8 @@ export interface AppState {
sessionStatuses: Record<string, SessionStatus>; sessionStatuses: Record<string, SessionStatus>;
sessionActivities: Record<string, SessionActivity>; sessionActivities: Record<string, SessionActivity>;
commandDialog: Extract<CommandResult, { type: "select" }> | undefined; commandDialog: Extract<CommandResult, { type: "select" }> | undefined;
modelDialog: { title: string; options: CommandOption[]; selectedValue?: string } | undefined;
thinkingDialog: { title: string; options: CommandOption[]; selectedValue?: string } | undefined;
actionPaletteOpen: boolean; actionPaletteOpen: boolean;
projectDialogOpen: boolean; projectDialogOpen: boolean;
workspaceTool: QualifiedContributionId; workspaceTool: QualifiedContributionId;
@@ -54,6 +56,8 @@ export function initialAppState(): AppState {
sessionStatuses: {}, sessionStatuses: {},
sessionActivities: {}, sessionActivities: {},
commandDialog: undefined, commandDialog: undefined,
modelDialog: undefined,
thinkingDialog: undefined,
actionPaletteOpen: false, actionPaletteOpen: false,
projectDialogOpen: false, projectDialogOpen: false,
workspaceTool: "core:workspace.files", workspaceTool: "core:workspace.files",
+32 -5
View File
@@ -7,12 +7,16 @@ import { commandPickerStyles } from "./shared";
@customElement("command-picker") @customElement("command-picker")
export class CommandPicker extends LitElement { export class CommandPicker extends LitElement {
@property() override title = "Select"; @property() override title = "Select";
@property({ type: Boolean }) searchable = false;
@property({ attribute: false }) options: CommandOption[] = []; @property({ attribute: false }) options: CommandOption[] = [];
@property({ attribute: false }) selectedValue?: string;
@property({ attribute: false }) onPick?: (value: string) => void; @property({ attribute: false }) onPick?: (value: string) => void;
@property({ attribute: false }) onCancel?: () => void; @property({ attribute: false }) onCancel?: () => void;
@state() private selectedIndex = 0; @state() private selectedIndex = 0;
@state() private query = "";
override render() { override render() {
const options = this.filteredOptions();
return html` return html`
<div class="backdrop" @mousedown=${() => this.onCancel?.()}> <div class="backdrop" @mousedown=${() => this.onCancel?.()}>
<section @mousedown=${(event: MouseEvent) => { event.stopPropagation(); }}> <section @mousedown=${(event: MouseEvent) => { event.stopPropagation(); }}>
@@ -20,13 +24,15 @@ export class CommandPicker extends LitElement {
<strong>${this.title}</strong> <strong>${this.title}</strong>
<button @click=${() => this.onCancel?.()}>×</button> <button @click=${() => this.onCancel?.()}>×</button>
</header> </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"> <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)}> <button class=${index === this.selectedIndex ? "selected" : ""} ${scrollWhenSelected(index === this.selectedIndex, option.value)} @click=${() => this.onPick?.(option.value)}>
<span>${option.label}</span> <span>${option.label}</span>
${option.description !== undefined && option.description !== "" ? html`<small>${option.description}</small>` : null} ${option.description !== undefined && option.description !== "" ? html`<small>${option.description}</small>` : null}
</button> </button>
`)} `)}
${options.length === 0 ? html`<div class="empty">No matching options</div>` : null}
</div> </div>
</section> </section>
</div> </div>
@@ -34,22 +40,43 @@ export class CommandPicker extends LitElement {
} }
override firstUpdated() { 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) { private handleKeyDown(event: KeyboardEvent) {
const options = this.filteredOptions();
if (event.key === "Escape") { if (event.key === "Escape") {
event.preventDefault(); event.preventDefault();
this.onCancel?.(); this.onCancel?.();
} else if (event.key === "ArrowDown") { } else if (event.key === "ArrowDown") {
event.preventDefault(); 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") { } else if (event.key === "ArrowUp") {
event.preventDefault(); 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") { } else if (event.key === "Enter") {
event.preventDefault(); event.preventDefault();
const option = this.options[this.selectedIndex]; const option = options[this.selectedIndex];
if (option) this.onPick?.(option.value); if (option) this.onPick?.(option.value);
} }
} }
+61 -2
View File
@@ -1,6 +1,6 @@
import { LitElement, html } from "lit"; import { LitElement, html } from "lit";
import { customElement, query, state } from "lit/decorators.js"; 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 type { AppAction } from "../actions";
import { initialAppState, type AppState } from "../appState"; import { initialAppState, type AppState } from "../appState";
import { FileExplorerController } from "../controllers/fileExplorerController"; import { FileExplorerController } from "../controllers/fileExplorerController";
@@ -246,6 +246,48 @@ export class PiWebApp extends LitElement {
if (action !== undefined) void action.run(); 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() { override render() {
const state = this.state; const state = this.state;
return html` return html`
@@ -264,8 +306,10 @@ export class PiWebApp extends LitElement {
${state.selectedSession ? html` ${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> <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> <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.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>`} ` : html`<div class="empty">Select or start a session.</div>`}
<div class="mobile-panel">${this.renderWorkspacePanel(true)}</div> <div class="mobile-panel">${this.renderWorkspacePanel(true)}</div>
</main> </main>
@@ -292,3 +336,18 @@ function isActive(status: AppState["status"]): boolean {
function nextFrame(): Promise<void> { function nextFrame(): Promise<void> {
return new Promise((resolve) => requestAnimationFrame(() => { resolve(); })); 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 { export class StatusBar extends LitElement {
@property({ attribute: false }) status?: SessionStatus; @property({ attribute: false }) status?: SessionStatus;
@property({ attribute: false }) workspace?: Workspace; @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() { override render() {
const status = this.status; const status = this.status;
@@ -24,8 +28,15 @@ export class StatusBar extends LitElement {
return html` return html`
<div class="bar"> <div class="bar">
<span title=${this.workspace?.path ?? ""}>${this.workspace?.label ?? "workspace"}</span> <span title=${this.workspace?.path ?? ""}>${this.workspace?.label ?? "workspace"}</span>
<span>${provider}${model}</span> <span class="control-group">
<span>thinking ${status.thinkingLevel ?? "off"}</span> <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.input)}</span>
<span>↓${formatTokenCount(tokens.output)}</span> <span>↓${formatTokenCount(tokens.output)}</span>
<span>${contextText}</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; } :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; } .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; } 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; } .bar > span:first-child { flex: 1 1 auto; min-width: 80px; }
.activity { display: inline-flex; align-items: center; gap: 6px; color: #8b949e; } .activity { display: inline-flex; align-items: center; gap: 6px; color: #8b949e; }
.activity.active { color: #3fb950; } .activity.active { color: #3fb950; }
@@ -251,9 +254,12 @@ export const commandPickerStyles = css`
.options { min-height: 0; overflow: auto; outline: none; } .options { min-height: 0; overflow: auto; outline: none; }
button { border: 0; background: transparent; color: #e6edf3; cursor: pointer; } button { border: 0; background: transparent; color: #e6edf3; cursor: pointer; }
header button { font-size: 20px; color: #8b949e; } 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 { display: block; width: 100%; padding: 10px 12px; border-bottom: 1px solid #21262d; text-align: left; }
.options button.selected, .options button:hover { background: #0d2847; } .options button.selected, .options button:hover { background: #0d2847; }
small { display: block; margin-top: 4px; color: #8b949e; } small { display: block; margin-top: 4px; color: #8b949e; }
.empty { padding: 24px; color: #8b949e; text-align: center; }
`; `;
export const actionPaletteStyles = css` 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; const MESSAGE_PAGE_SIZE = 100;
import { normalizeMessages, textMessage } from "../chatMessages"; 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() { async stopActiveWork() {
const session = this.getState().selectedSession; const session = this.getState().selectedSession;
if (!session) return; 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("/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) => { app.get<{ Params: { sessionId: string } }>("/api/sessions/:sessionId/events", { websocket: true }, (socket, request) => {
bridgeSockets(socket, daemon.connectWebSocket(`/sessions/${request.params.sessionId}/events`)); 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) => { app.get("/api/sessions/events", { websocket: true }, (socket) => {
bridgeSockets(socket, daemon.connectWebSocket("/sessions/events")); 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 { function stripApiPrefix(url: string): string {
+77 -19
View File
@@ -10,7 +10,7 @@ import {
type AgentSession, type AgentSession,
type CreateAgentSessionRuntimeFactory, type CreateAgentSessionRuntimeFactory,
} from "@earendil-works/pi-coding-agent"; } 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 type { SessionEventHub } from "../realtime/sessionEventHub.js";
import { BUILTIN_COMMANDS } from "./builtinCommands.js"; import { BUILTIN_COMMANDS } from "./builtinCommands.js";
import { SessionCommandService } from "./sessionCommandService.js"; import { SessionCommandService } from "./sessionCommandService.js";
@@ -151,6 +151,65 @@ export class PiSessionService {
return this.statusFromSession(await this.getOrOpen(sessionId)); 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[]> { async commands(sessionId: string): Promise<ClientCommand[]> {
const session = await this.getOrOpen(sessionId); const session = await this.getOrOpen(sessionId);
const commands: ClientCommand[] = [...BUILTIN_COMMANDS]; const commands: ClientCommand[] = [...BUILTIN_COMMANDS];
@@ -402,19 +461,7 @@ export class PiSessionService {
private statusFromSession(session: AgentSession): ClientSessionStatus { private statusFromSession(session: AgentSession): ClientSessionStatus {
const stats = session.getSessionStats(); const stats = session.getSessionStats();
const model = session.model === undefined const model = session.model === undefined ? undefined : modelToClientModel(session.model);
? 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 contextUsage = session.getContextUsage(); const contextUsage = session.getContextUsage();
return { return {
sessionId: session.sessionId, 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> { async function clearParentSession(sessionFile: string): Promise<void> {
const content = await readFile(sessionFile, "utf8"); const content = await readFile(sessionFile, "utf8");
const newlineIndex = content.indexOf("\n"); const newlineIndex = content.indexOf("\n");
@@ -445,8 +505,7 @@ async function clearParentSession(sessionFile: string): Promise<void> {
} }
function clearSessionQueue(session: AgentSession): void { function clearSessionQueue(session: AgentSession): void {
const candidate = session as AgentSession & { clearQueue?: () => unknown }; session.clearQueue();
candidate.clearQueue?.();
} }
function hasQueuedMessageText(session: AgentSession, text: string): boolean { 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 }[] { function queuedMessagesFromSession(session: AgentSession): { kind: "steer" | "followUp"; text: string }[] {
const candidate = session as AgentSession & { getSteeringMessages?: () => string[]; getFollowUpMessages?: () => string[] };
return [ return [
...(candidate.getSteeringMessages?.() ?? []).map((text) => ({ kind: "steer" as const, text })), ...session.getSteeringMessages().map((text) => ({ kind: "steer" as const, text })),
...(candidate.getFollowUpMessages?.() ?? []).map((text) => ({ kind: "followUp" 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) => { app.get<{ Params: { sessionId: string } }>(`${prefix}/sessions/:sessionId/commands`, async (request, reply) => {
try { try {
return await sessions.commands(request.params.sessionId); return await sessions.commands(request.params.sessionId);
+2
View File
@@ -4,6 +4,8 @@ export type {
SessionInfo as ClientSession, SessionInfo as ClientSession,
MessagePage as ClientMessagePage, MessagePage as ClientMessagePage,
SessionStatus as ClientSessionStatus, SessionStatus as ClientSessionStatus,
SessionModel as ClientSessionModel,
ThinkingLevel as ClientThinkingLevel,
SlashCommand as ClientCommand, SlashCommand as ClientCommand,
FileSuggestion as ClientFileSuggestion, FileSuggestion as ClientFileSuggestion,
CommandOption as ClientCommandOption, CommandOption as ClientCommandOption,
+19 -1
View File
@@ -43,9 +43,27 @@ export interface QueuedSessionMessage {
text: string; 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 { export interface SessionStatus {
sessionId: string; sessionId: string;
model?: { provider?: string; id?: string; name?: string; contextWindow?: number; reasoning?: unknown }; model?: SessionModel;
thinkingLevel?: string; thinkingLevel?: string;
isStreaming: boolean; isStreaming: boolean;
isCompacting: boolean; isCompacting: boolean;