refactor: source thinking levels from pi and make the gauge dynamic

Depend on @earendil-works/pi-agent-core so the ThinkingLevel union has a single source of truth (re-exported via shared/thinkingLevels). Wire/data fields use string and the parser is lenient, so an unknown level from a newer pi runtime is still listed, selectable, and rendered gracefully instead of throwing. The composer gauge now derives its bar count from the levels available for the current model and fills by rank. Adds compile-time drift guards (satisfies + Exclude check) and unit tests so a changed pi level set fails fast in development.
This commit is contained in:
Federico Jaramillo Martinez
2026-06-14 13:47:44 +02:00
parent 411e61ac75
commit ca30c970a7
15 changed files with 212 additions and 51 deletions
+1 -1
View File
@@ -128,7 +128,7 @@ export const sessionsApi = {
setModel: (session: SessionLookup, provider: string, modelId: string, machineId = "local") => request(sessionUrl(session, "model", machineId), parseSessionStatus, { method: "POST", body: sessionBody(session, { provider, modelId }) }),
cycleModel: (session: SessionLookup, direction: "forward" | "backward", machineId = "local") => request(sessionUrl(session, "model/cycle", machineId), parseSessionStatus, { method: "POST", body: sessionBody(session, { direction }) }),
thinkingLevels: (session: SessionLookup, machineId = "local") => request(sessionQueryUrl(session, "thinking-levels", machineId), parseThinkingLevelsResponse),
setThinkingLevel: (session: SessionLookup, level: "off" | "minimal" | "low" | "medium" | "high" | "xhigh", machineId = "local") => request(sessionUrl(session, "thinking-level", machineId), parseSessionStatus, { method: "POST", body: sessionBody(session, { level }) }),
setThinkingLevel: (session: SessionLookup, level: string, machineId = "local") => request(sessionUrl(session, "thinking-level", machineId), parseSessionStatus, { method: "POST", body: sessionBody(session, { level }) }),
cycleThinkingLevel: (session: SessionLookup, machineId = "local") => request(sessionUrl(session, "thinking-level/cycle", machineId), parseSessionStatus, { method: "POST", body: sessionBody(session) }),
commands: (session: SessionLookup, machineId = "local") => request(sessionQueryUrl(session, "commands", machineId), arrayOf(parseSlashCommand)),
prompt: (session: SessionLookup, text: string, streamingBehavior?: "steer" | "followUp", machineId = "local", attachments?: PromptAttachment[]) => request(sessionUrl(session, "prompt", machineId), parseAccepted, { method: "POST", body: sessionBody(session, { text, ...(streamingBehavior === undefined ? {} : { streamingBehavior }), ...(attachments !== undefined && attachments.length > 0 ? { attachments } : {}) }) }),
+5 -3
View File
@@ -1,4 +1,4 @@
import type { ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, Machine, MachineHealth, MachineKind, MachineRuntime, MachineStatus, MessagePage, ModelSelectionResponse, OAuthFlowState, PiWebCapability, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebInstallationInfo, PiWebPluginConfigMap, PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope, PiWebReleaseStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebServiceComponent, PiWebShortcutConfig, PiWebStatusMessage, PiWebStatusResponse, PiWebStatusSeverity, Project, QueuedSessionMessage, SavedPromptAttachment, SessionInfo, SessionModel, SessionStatus, SlashCommand, TerminalCommandRun, TerminalCommandRunStatus, TerminalInfo, ThinkingLevel, ThinkingLevelsResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse } from "../../../shared/apiTypes";
import type { ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, Machine, MachineHealth, MachineKind, MachineRuntime, MachineStatus, MessagePage, ModelSelectionResponse, OAuthFlowState, PiWebCapability, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebInstallationInfo, PiWebPluginConfigMap, PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope, PiWebReleaseStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebServiceComponent, PiWebShortcutConfig, PiWebStatusMessage, PiWebStatusResponse, PiWebStatusSeverity, Project, QueuedSessionMessage, SavedPromptAttachment, SessionInfo, SessionModel, SessionStatus, SlashCommand, TerminalCommandRun, TerminalCommandRunStatus, TerminalInfo, ThinkingLevelsResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse } from "../../../shared/apiTypes";
import { isPiWebCapability } from "../../../shared/capabilities";
function isRecord(value: unknown): value is Record<string, unknown> {
@@ -215,8 +215,10 @@ export function parseModelSelectionResponse(value: unknown): ModelSelectionRespo
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");
function parseThinkingLevel(value: unknown): string {
// pi owns the level set; accept any string so a newer pi runtime reporting an
// unknown level degrades gracefully instead of failing the whole response.
if (typeof value !== "string") throw new Error("Invalid thinking level");
return value;
}
+3
View File
@@ -26,6 +26,8 @@ export interface AppState {
selectedSession: SessionInfo | undefined;
status: SessionStatus | undefined;
activity: SessionActivity | undefined;
/** Thinking levels available for the selected session's current model. */
availableThinkingLevels: readonly string[];
sessionStatuses: Record<string, SessionStatus>;
sessionActivities: Record<string, SessionActivity>;
workspaceActivities: Record<string, WorkspaceActivity>;
@@ -123,6 +125,7 @@ export function initialAppState(): AppState {
selectedSession: undefined,
status: undefined,
activity: undefined,
availableThinkingLevels: [],
sessionStatuses: {},
sessionActivities: {},
workspaceActivities: {},
+6 -9
View File
@@ -1,6 +1,6 @@
import { LitElement, html } from "lit";
import { customElement, query, state } from "lit/decorators.js";
import { configApi, piWebApi, terminalsApi, workspacesApi, type Machine, type MachineHealth, type PiWebConfigValues, type PiWebShortcutConfig, type Project, type RealtimeEvent, type SessionInfo, type TerminalCommandRun, type TerminalUiEvent, type ThinkingLevel, type Workspace } from "../api";
import { configApi, piWebApi, terminalsApi, workspacesApi, type Machine, type MachineHealth, type PiWebConfigValues, type PiWebShortcutConfig, type Project, type RealtimeEvent, type SessionInfo, type TerminalCommandRun, type TerminalUiEvent, type Workspace } from "../api";
import type { AppAction } from "../actions";
import { initialAppState, type AppState } from "../appState";
import { isSessionActive } from "../../../shared/activity";
@@ -1655,14 +1655,14 @@ export class PiWebApp extends LitElement {
thinkingDialog: {
title: "Select Thinking Level",
selectedValue: current,
options: levels.map((level) => ({ value: level, label: `${level}${level === current ? " ✓ current" : ""}`, description: thinkingDescription(level) })),
options: levels.map((level) => { const description = thinkingDescription(level); return { value: level, label: `${level}${level === current ? " ✓ current" : ""}`, ...(description === undefined ? {} : { description }) }; }),
},
});
}
private async pickThinking(value: string) {
this.setState({ thinkingDialog: undefined });
if (isThinkingLevel(value)) await this.sessions.setThinkingLevel(value);
if (value !== "") await this.sessions.setThinkingLevel(value);
}
private sendPrompt(text: string, streamingBehavior?: "steer" | "followUp", attachments?: import("../api").PromptAttachment[], delivery?: import("../../../shared/apiTypes").PromptAttachmentDelivery): void {
@@ -1730,7 +1730,7 @@ export class PiWebApp extends LitElement {
<div class="mobile-navigation-panel">${this.appShell.isMobileNavigationLayout ? this.renderNavigationPanel() : null}</div>
${state.selectedSession ? html`
<chat-view .sessionId=${state.selectedSession.id} .messages=${state.messages} .messageStart=${state.messagePageStart} .messageEnd=${state.messagePageEnd} .messageTotal=${state.messagePageTotal} .hasMore=${state.messagePageStart > 0} .loadingMore=${state.isLoadingEarlierMessages} .isReceivingPartialStream=${state.isReceivingPartialStream} .isSendingPrompt=${state.sendingPrompts[state.selectedSession.id] === true} .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} .machineId=${selectedMachineId(state)} .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} .status=${state.status} .sending=${state.sendingPrompts[state.selectedSession.id] === true} .onSend=${(text: string, streamingBehavior?: "steer" | "followUp", attachments?: import("../api").PromptAttachment[], delivery?: import("../../../shared/apiTypes").PromptAttachmentDelivery) => { this.sendPrompt(text, streamingBehavior, attachments, delivery); }} .onStop=${() => this.sessions.stopActiveWork()} .onSelectModel=${() => { void this.openModelDialog(); }} .onSelectThinking=${() => { void this.openThinkingDialog(); }}></prompt-editor>
<prompt-editor .sessionId=${state.selectedSession.id} .cwd=${state.selectedWorkspace?.path} .machineId=${selectedMachineId(state)} .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} .status=${state.status} .availableThinkingLevels=${state.availableThinkingLevels} .sending=${state.sendingPrompts[state.selectedSession.id] === true} .onSend=${(text: string, streamingBehavior?: "steer" | "followUp", attachments?: import("../api").PromptAttachment[], delivery?: import("../../../shared/apiTypes").PromptAttachmentDelivery) => { this.sendPrompt(text, streamingBehavior, attachments, delivery); }} .onStop=${() => this.sessions.stopActiveWork()} .onSelectModel=${() => { void this.openModelDialog(); }} .onSelectThinking=${() => { void this.openThinkingDialog(); }}></prompt-editor>
<status-bar .status=${state.status}></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}
@@ -1818,11 +1818,7 @@ 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 {
function thinkingDescription(level: string): string | undefined {
switch (level) {
case "off": return "No reasoning";
case "minimal": return "Very brief reasoning (~1k tokens)";
@@ -1830,5 +1826,6 @@ function thinkingDescription(level: ThinkingLevel): string {
case "medium": return "Moderate reasoning (~8k tokens)";
case "high": return "Deep reasoning (~16k tokens)";
case "xhigh": return "Maximum reasoning (~32k tokens)";
default: return undefined; // unknown level from a newer pi: no description
}
}
+4 -2
View File
@@ -14,7 +14,8 @@ import { detectPromptCompletionTrigger, fileCompletionInsertText, type PromptCom
import { clearDraft, loadDraft, saveDraft } from "../promptDraftStorage";
import { loadAttachmentDelivery, saveAttachmentDelivery } from "../attachmentPreferences";
import { promptEditorStyles, type CompletionItem } from "./shared";
import { renderAttachIcon, renderSendIcon, renderQueueIcon, renderSteerIcon, renderStopIcon, renderThinkingGauge, thinkingLevelLabel } from "./promptEditorIcons";
import { renderAttachIcon, renderSendIcon, renderQueueIcon, renderSteerIcon, renderStopIcon, renderThinkingGauge } from "./promptEditorIcons";
import { thinkingGauge, thinkingLevelLabel } from "../../../shared/thinkingLevels";
import "./AutocompleteMenu";
interface PendingAttachment {
@@ -41,6 +42,7 @@ export class PromptEditor extends LitElement {
@property({ attribute: false }) onStop?: () => void;
@property({ attribute: false }) onSelectModel?: () => void;
@property({ attribute: false }) onSelectThinking?: () => void;
@property({ attribute: false }) availableThinkingLevels: readonly string[] = [];
@query(".markdown-editor") private editorHost?: HTMLDivElement;
@query(".attachment-input") private attachmentInput?: HTMLInputElement;
@state() private draft = "";
@@ -120,7 +122,7 @@ export class PromptEditor extends LitElement {
return html`
<div class="compact-status" aria-label="Session status">
<button class="select-model" title="Select model" @click=${() => this.onSelectModel?.()}>${provider}${model}</button>
<button class="select-thinking icon-button" title=${`Thinking level: ${thinkingLevelLabel(status.thinkingLevel)}`} aria-label=${`Thinking level: ${thinkingLevelLabel(status.thinkingLevel)}`} @click=${() => this.onSelectThinking?.()}>${renderThinkingGauge(status.thinkingLevel)}</button>
<button class="select-thinking icon-button" title=${`Thinking level: ${thinkingLevelLabel(status.thinkingLevel)}`} aria-label=${`Thinking level: ${thinkingLevelLabel(status.thinkingLevel)}`} @click=${() => this.onSelectThinking?.()}>${renderThinkingGauge(thinkingGauge(status.thinkingLevel, this.availableThinkingLevels))}</button>
</div>
`;
}
+18 -21
View File
@@ -1,4 +1,5 @@
import { svg, type TemplateResult } from "lit";
import type { ThinkingGauge } from "../../../shared/thinkingLevels";
// Hand-rolled inline icons matching the project's stroke style
// (viewBox 0 0 24 24, fill none, stroke currentColor, round caps/joins).
@@ -45,28 +46,24 @@ export function renderStopIcon(): TemplateResult {
`;
}
const THINKING_LEVEL_STEPS: Record<string, number> = {
off: 0,
minimal: 1,
low: 2,
medium: 3,
high: 4,
xhigh: 5,
};
export function thinkingLevelLabel(level: string | undefined): string {
return level === undefined || level === "" ? "off" : level;
}
/** A 5-bar gauge that fills up to the active thinking level. */
export function renderThinkingGauge(level: string | undefined): TemplateResult {
const steps = THINKING_LEVEL_STEPS[level ?? "off"] ?? 0;
const bars = [0, 1, 2, 3, 4].map((i) => {
const x = 3 + i * 4;
const height = 4 + i * 3;
/**
* A gauge whose bar count comes from the available thinking levels (the non-"off"
* levels) and whose fill reflects the current level's rank. Bars are laid out to
* fill the 24x24 box regardless of count, so it adapts if pi changes the set.
*/
export function renderThinkingGauge(gauge: ThinkingGauge): TemplateResult {
const total = Math.max(gauge.total, 1);
const gap = total > 1 ? 1.2 : 0;
const left = 3;
const right = 21;
const span = right - left;
const barWidth = (span - gap * (total - 1)) / total;
const bars = Array.from({ length: total }, (_unused, i) => {
const x = left + i * (barWidth + gap);
const height = 4 + ((i + 1) / total) * 12;
const y = 20 - height;
const active = i < steps;
return svg`<rect class=${active ? "gauge-bar gauge-bar-active" : "gauge-bar"} x=${x} y=${y} width="2.6" height=${height} rx="1"></rect>`;
const active = i < gauge.filled;
return svg`<rect class=${active ? "gauge-bar gauge-bar-active" : "gauge-bar"} x=${x} y=${y} width=${barWidth} height=${height} rx="1"></rect>`;
});
return svg`
<svg class="prompt-thinking-gauge" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
@@ -1,4 +1,4 @@
import { api as defaultApi, type CommandResult, type PromptAttachment, type SessionActivity, type SessionInfo, type SessionRef, type SessionStatus, type ThinkingLevel } from "../api";
import { api as defaultApi, type CommandResult, type PromptAttachment, type SessionActivity, type SessionInfo, type SessionRef, type SessionStatus } from "../api";
import type { AppState } from "../appState";
import { forgetCachedNewSession, isCachedNewSessionInfo, markCachedNewSessionInfo, rememberCachedNewSession, stripCachedNewSessionMarker } from "../cachedNewSessions";
import { textMessage } from "../chatMessages";
@@ -68,7 +68,7 @@ export class SessionController {
// session must not cancel the in-flight upload indicator of the session
// that is still sending; the per-session entry is cleared by send()'s
// finally block when the request settles.
this.setState({ selectedSession: undefined, messages: [], messagePageStart: 0, messagePageEnd: 0, messagePageTotal: 0, isLoadingEarlierMessages: false, isReceivingPartialStream: false, status: undefined, activity: undefined });
this.setState({ selectedSession: undefined, messages: [], messagePageStart: 0, messagePageEnd: 0, messagePageTotal: 0, isLoadingEarlierMessages: false, isReceivingPartialStream: false, status: undefined, activity: undefined, availableThinkingLevels: [] });
}
deselectSession(options?: { forgetRememberedSelection?: boolean | undefined; updateUrl?: boolean | undefined }) {
@@ -141,8 +141,9 @@ export class SessionController {
const history = this.transcripts.mergeHistory(transcriptKey, page);
const isReceivingPartialStream = status.isStreaming;
this.catchupStreamSessionId = isReceivingPartialStream ? session.id : undefined;
this.setState({ ...history, isLoadingEarlierMessages: false, isReceivingPartialStream, status, activity: this.getState().sessionActivities[session.id] });
this.setState({ ...history, isLoadingEarlierMessages: false, isReceivingPartialStream, status, activity: this.getState().sessionActivities[session.id], availableThinkingLevels: [] });
this.applyStatus(status);
void this.refreshAvailableThinkingLevels();
for (const event of buffered) this.applyEvent(event);
this.socket.setHandler((event) => { this.applyEvent(event); });
if (options?.updateUrl !== false) this.updateUrl();
@@ -406,6 +407,7 @@ export class SessionController {
if (!session || session.archived === true) return;
try {
this.applyStatus(await this.api.setModel(session, provider, modelId, selectedMachineId(this.getState())));
await this.refreshAvailableThinkingLevels();
} catch (error) {
this.setState({ error: String(error) });
}
@@ -416,6 +418,7 @@ export class SessionController {
if (!session || session.archived === true) return;
try {
this.applyStatus(await this.api.cycleModel(session, direction, selectedMachineId(this.getState())));
await this.refreshAvailableThinkingLevels();
} catch (error) {
this.setState({ error: String(error) });
}
@@ -432,7 +435,19 @@ export class SessionController {
}
}
async setThinkingLevel(level: ThinkingLevel) {
/** Refresh the available thinking levels for the selected session's model. */
async refreshAvailableThinkingLevels() {
const session = this.getState().selectedSession;
if (!session || session.archived === true) {
if (this.getState().availableThinkingLevels.length > 0) this.setState({ availableThinkingLevels: [] });
return;
}
const levels = await this.listThinkingLevels();
if (this.getState().selectedSession?.id !== session.id) return;
this.setState({ availableThinkingLevels: levels });
}
async setThinkingLevel(level: string) {
const session = this.getState().selectedSession;
if (!session || session.archived === true) return;
try {