From ca30c970a7029f66eab968dc0f48927392f1209f Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Sun, 14 Jun 2026 13:47:44 +0200 Subject: [PATCH] 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. --- .changeset/prompt-composer-icon-actions.md | 2 +- package-lock.json | 24 ++++++- package.json | 2 + src/client/src/api/clients.ts | 2 +- src/client/src/api/parsers.ts | 8 ++- src/client/src/appState.ts | 3 + src/client/src/components/PiWebApp.ts | 15 ++--- src/client/src/components/PromptEditor.ts | 6 +- .../src/components/promptEditorIcons.ts | 39 +++++------ .../src/controllers/sessionController.ts | 23 +++++-- src/server/sessions/piSessionService.ts | 9 ++- src/server/sessions/sessionRoutes.ts | 8 ++- src/shared/apiTypes.ts | 7 +- src/shared/thinkingLevels.test.ts | 67 +++++++++++++++++++ src/shared/thinkingLevels.ts | 48 +++++++++++++ 15 files changed, 212 insertions(+), 51 deletions(-) create mode 100644 src/shared/thinkingLevels.test.ts create mode 100644 src/shared/thinkingLevels.ts diff --git a/.changeset/prompt-composer-icon-actions.md b/.changeset/prompt-composer-icon-actions.md index b857bc3..323c6c8 100644 --- a/.changeset/prompt-composer-icon-actions.md +++ b/.changeset/prompt-composer-icon-actions.md @@ -2,4 +2,4 @@ "@jmfederico/pi-web": patch --- -Declutter the chat composer bar with icon-based actions. The Send, Queue, Steer, and Stop buttons are now compact icons, the Attach button moved into the message box, and the thinking level is shown as a small gauge that fills with the selected level. This leaves more room on narrow/mobile layouts while keeping the model selector readable. All controls retain accessible labels and tooltips. +Declutter the chat composer bar with icon-based actions. The Send, Queue, Steer, and Stop buttons are now compact icons, the Attach button moved into the message box, and the thinking level is shown as a small gauge whose bars reflect the levels available for the current model. This leaves more room on narrow/mobile layouts while keeping the model selector readable. All controls retain accessible labels and tooltips. Thinking levels are now sourced from pi directly, so an unfamiliar level from a newer pi version is still selectable and displayed gracefully instead of causing an error. diff --git a/package-lock.json b/package-lock.json index b32d26d..b44516b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -40,6 +40,7 @@ }, "devDependencies": { "@changesets/cli": "^2.31.0", + "@earendil-works/pi-agent-core": "^0.79.1", "@earendil-works/pi-ai": "^0.79.1", "@earendil-works/pi-coding-agent": "^0.79.1", "@eslint/js": "^10.0.1", @@ -58,6 +59,7 @@ "node": ">=22" }, "peerDependencies": { + "@earendil-works/pi-agent-core": ">=0.78.0 <1", "@earendil-works/pi-ai": ">=0.78.0 <1", "@earendil-works/pi-coding-agent": ">=0.78.0 <1" } @@ -997,10 +999,26 @@ "w3c-keyname": "^2.2.4" } }, + "node_modules/@earendil-works/pi-agent-core": { + "version": "0.79.3", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.79.3.tgz", + "integrity": "sha512-Ksvnu6CpQLYGbCSgnQEetzliI7yb+QkqtSlmmunJ69QluT45kd3DjQZRNHfRLk++Dd02Y8QvsRKMopSJCcWoWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@earendil-works/pi-ai": "^0.79.3", + "ignore": "7.0.5", + "typebox": "1.1.38", + "yaml": "2.9.0" + }, + "engines": { + "node": ">=22.19.0" + } + }, "node_modules/@earendil-works/pi-ai": { - "version": "0.79.1", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.79.1.tgz", - "integrity": "sha512-UnORwrcsTNLm4StEvoM8iEom0u87Te7BXEWxhec3iNXygWD6eEBosUoq9ddcveqtj/QpUZBMPWUu81cCtZxzkQ==", + "version": "0.79.3", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.79.3.tgz", + "integrity": "sha512-lMSput/haP5uZAGbXhS5rAYd3GB7GYdJkoAUxg3VFummBeqGqGqllaTWrbHFN12kVGyVfWHhdySNXkiqVh65Iw==", "dev": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 8385a8d..11e045c 100644 --- a/package.json +++ b/package.json @@ -75,6 +75,7 @@ }, "devDependencies": { "@changesets/cli": "^2.31.0", + "@earendil-works/pi-agent-core": "^0.79.1", "@earendil-works/pi-ai": "^0.79.1", "@earendil-works/pi-coding-agent": "^0.79.1", "@eslint/js": "^10.0.1", @@ -105,6 +106,7 @@ "homepage": "https://pi-web.dev/", "packageManager": "npm@11.11.0", "peerDependencies": { + "@earendil-works/pi-agent-core": ">=0.78.0 <1", "@earendil-works/pi-ai": ">=0.78.0 <1", "@earendil-works/pi-coding-agent": ">=0.78.0 <1" }, diff --git a/src/client/src/api/clients.ts b/src/client/src/api/clients.ts index 3118678..bfda54a 100644 --- a/src/client/src/api/clients.ts +++ b/src/client/src/api/clients.ts @@ -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 } : {}) }) }), diff --git a/src/client/src/api/parsers.ts b/src/client/src/api/parsers.ts index 2c5dfc7..a9edf01 100644 --- a/src/client/src/api/parsers.ts +++ b/src/client/src/api/parsers.ts @@ -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 { @@ -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; } diff --git a/src/client/src/appState.ts b/src/client/src/appState.ts index aec36db..dc59f43 100644 --- a/src/client/src/appState.ts +++ b/src/client/src/appState.ts @@ -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; sessionActivities: Record; workspaceActivities: Record; @@ -123,6 +125,7 @@ export function initialAppState(): AppState { selectedSession: undefined, status: undefined, activity: undefined, + availableThinkingLevels: [], sessionStatuses: {}, sessionActivities: {}, workspaceActivities: {}, diff --git a/src/client/src/components/PiWebApp.ts b/src/client/src/components/PiWebApp.ts index 1dcce9e..b58b1c2 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 { 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 {
${this.appShell.isMobileNavigationLayout ? this.renderNavigationPanel() : null}
${state.selectedSession ? html` 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())}> - 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(); }}> + 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(); }}> ${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} @@ -1818,11 +1818,7 @@ 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 { +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 } } diff --git a/src/client/src/components/PromptEditor.ts b/src/client/src/components/PromptEditor.ts index 8f6a734..3d8eb40 100644 --- a/src/client/src/components/PromptEditor.ts +++ b/src/client/src/components/PromptEditor.ts @@ -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`
- +
`; } diff --git a/src/client/src/components/promptEditorIcons.ts b/src/client/src/components/promptEditorIcons.ts index 9b9c246..9a796f6 100644 --- a/src/client/src/components/promptEditorIcons.ts +++ b/src/client/src/components/promptEditorIcons.ts @@ -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 = { - 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``; + const active = i < gauge.filled; + return svg``; }); return svg`