Archived
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:
@@ -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.
|
||||
|
||||
Generated
+21
-3
@@ -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": {
|
||||
|
||||
@@ -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": "[email protected]",
|
||||
"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"
|
||||
},
|
||||
|
||||
@@ -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 } : {}) }) }),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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: {},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -378,10 +378,15 @@ export class PiSessionService {
|
||||
return session.getAvailableThinkingLevels();
|
||||
}
|
||||
|
||||
async setThinkingLevel(ref: PiSessionLookup, level: ClientThinkingLevel): Promise<ClientSessionStatus> {
|
||||
async setThinkingLevel(ref: PiSessionLookup, level: string): Promise<ClientSessionStatus> {
|
||||
await this.assertWritable(ref);
|
||||
const session = await this.getOrOpen(ref);
|
||||
session.setThinkingLevel(level);
|
||||
// pi owns the valid set; validate against the session's live levels rather
|
||||
// than a hardcoded union so this stays correct if pi changes the set.
|
||||
const available = session.getAvailableThinkingLevels();
|
||||
const match = available.find((candidate) => candidate === level);
|
||||
if (match === undefined) throw new Error(`Invalid thinking level: ${level}`);
|
||||
session.setThinkingLevel(match);
|
||||
this.publishActivity(session, `thinking: ${session.thinkingLevel}`, "idle");
|
||||
this.publishStatus(session);
|
||||
return this.statusFromSession(session);
|
||||
|
||||
@@ -102,6 +102,8 @@ export function registerSessionRoutes(app: FastifyInstance, sessions: PiSessionS
|
||||
app.post<{ Params: { sessionId: string }; Body: { cwd?: unknown; level?: unknown } | undefined }>(`${prefix}/sessions/:sessionId/thinking-level`, async (request, reply) => {
|
||||
try {
|
||||
const body = optionalRecord(request.body);
|
||||
// The level string is validated against the session's live available levels
|
||||
// in the service, so it stays correct if pi changes the set.
|
||||
return await sessions.setThinkingLevel(sessionLookupFromBody(request.params.sessionId, body), requireThinkingLevel(body["level"]));
|
||||
} catch (error) {
|
||||
return reply.code(mutationErrorStatus(error)).send({ error: errorMessage(error) });
|
||||
@@ -285,9 +287,9 @@ function requireString(record: Record<string, unknown>, field: string): string {
|
||||
return value;
|
||||
}
|
||||
|
||||
function requireThinkingLevel(value: unknown): "off" | "minimal" | "low" | "medium" | "high" | "xhigh" {
|
||||
if (value === "off" || value === "minimal" || value === "low" || value === "medium" || value === "high" || value === "xhigh") return value;
|
||||
throw new Error("level field is invalid");
|
||||
function requireThinkingLevel(value: unknown): string {
|
||||
if (typeof value !== "string" || value === "") throw new Error("level field is invalid");
|
||||
return value;
|
||||
}
|
||||
|
||||
function optionalField<T>(key: string, value: T | undefined): Record<string, T> | object {
|
||||
|
||||
@@ -183,7 +183,10 @@ export interface SessionModel {
|
||||
reasoning?: unknown;
|
||||
}
|
||||
|
||||
export type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh";
|
||||
// Domain type is owned by pi and re-exported from the shared thinking-levels
|
||||
// module. Wire/data fields below intentionally use `string` so an unknown level
|
||||
// from a newer pi runtime parses and renders gracefully instead of failing.
|
||||
export type { ThinkingLevel } from "./thinkingLevels.js";
|
||||
|
||||
export type AuthType = "oauth" | "api_key";
|
||||
export type AuthStatusSource = "stored" | "runtime" | "environment" | "fallback" | "models_json_key" | "models_json_command";
|
||||
@@ -222,7 +225,7 @@ export interface ModelSelectionResponse {
|
||||
}
|
||||
|
||||
export interface ThinkingLevelsResponse {
|
||||
levels: ThinkingLevel[];
|
||||
levels: string[];
|
||||
}
|
||||
|
||||
export interface SessionStatus {
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
|
||||
import { KNOWN_THINKING_LEVELS, isKnownThinkingLevel, thinkingGauge, thinkingLevelLabel } from "./thinkingLevels";
|
||||
|
||||
// Compile-time drift guard: if pi ADDS a thinking level we do not know about,
|
||||
// `Extra` becomes that level and this assignment fails to type-check. Combined
|
||||
// with the `satisfies readonly ThinkingLevel[]` clause in thinkingLevels.ts
|
||||
// (which catches removals/renames), this pins KNOWN_THINKING_LEVELS to pi's
|
||||
// union exactly. When this breaks, update KNOWN_THINKING_LEVELS and give the new
|
||||
// level a label/description where thinking levels are presented.
|
||||
type Extra = Exclude<ThinkingLevel, (typeof KNOWN_THINKING_LEVELS)[number]>;
|
||||
const _noUnknownLevels: Extra extends never ? true : never = true;
|
||||
void _noUnknownLevels;
|
||||
|
||||
describe("thinkingLevels", () => {
|
||||
it("recognizes all known levels and rejects others", () => {
|
||||
for (const level of KNOWN_THINKING_LEVELS) expect(isKnownThinkingLevel(level)).toBe(true);
|
||||
expect(isKnownThinkingLevel("ultra")).toBe(false);
|
||||
expect(isKnownThinkingLevel("")).toBe(false);
|
||||
});
|
||||
|
||||
it("labels levels, defaulting empty/undefined to off", () => {
|
||||
expect(thinkingLevelLabel(undefined)).toBe("off");
|
||||
expect(thinkingLevelLabel("")).toBe("off");
|
||||
expect(thinkingLevelLabel("high")).toBe("high");
|
||||
expect(thinkingLevelLabel("brand-new-level")).toBe("brand-new-level");
|
||||
});
|
||||
|
||||
describe("thinkingGauge", () => {
|
||||
const known = KNOWN_THINKING_LEVELS;
|
||||
|
||||
it("derives bar count from the available set (excluding the off level)", () => {
|
||||
// 6 known levels => 5 bars.
|
||||
expect(thinkingGauge("off", known).total).toBe(5);
|
||||
expect(thinkingGauge("off", ["off", "low", "high"]).total).toBe(2);
|
||||
});
|
||||
|
||||
it("treats the first level as no thinking (0 filled)", () => {
|
||||
expect(thinkingGauge("off", known)).toEqual({ total: 5, filled: 0 });
|
||||
expect(thinkingGauge(undefined, known)).toEqual({ total: 5, filled: 0 });
|
||||
});
|
||||
|
||||
it("fills up to the current level's rank", () => {
|
||||
expect(thinkingGauge("minimal", known).filled).toBe(1);
|
||||
expect(thinkingGauge("low", known).filled).toBe(2);
|
||||
expect(thinkingGauge("medium", known).filled).toBe(3);
|
||||
expect(thinkingGauge("high", known).filled).toBe(4);
|
||||
expect(thinkingGauge("xhigh", known).filled).toBe(5);
|
||||
});
|
||||
|
||||
it("adapts to a runtime-provided set of a different size", () => {
|
||||
const available = ["off", "low", "high"];
|
||||
expect(thinkingGauge("off", available)).toEqual({ total: 2, filled: 0 });
|
||||
expect(thinkingGauge("low", available)).toEqual({ total: 2, filled: 1 });
|
||||
expect(thinkingGauge("high", available)).toEqual({ total: 2, filled: 2 });
|
||||
});
|
||||
|
||||
it("falls back to the known set when no usable available set is given", () => {
|
||||
expect(thinkingGauge("high", [])).toEqual({ total: 5, filled: 4 });
|
||||
expect(thinkingGauge("high", ["only-one"])).toEqual({ total: 5, filled: 4 });
|
||||
});
|
||||
|
||||
it("fills 0 for an unknown current level instead of throwing", () => {
|
||||
expect(thinkingGauge("brand-new-level", known).filled).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
|
||||
|
||||
// pi owns the set of thinking levels. We re-export pi's type so the domain has a
|
||||
// single source of truth, while the HTTP/wire contract (apiTypes.ts) keeps using
|
||||
// `string` so an unknown level reported by a newer pi runtime degrades gracefully
|
||||
// instead of failing to parse.
|
||||
export type { ThinkingLevel };
|
||||
|
||||
/**
|
||||
* Known levels in increasing intensity, derived from pi's `ThinkingLevel` union.
|
||||
* The `satisfies` clause makes this fail to compile if pi removes or renames a
|
||||
* level; thinkingLevels.test.ts adds a compile-time check for additions too. When
|
||||
* either breaks, update this list and give the new level a label/description
|
||||
* where thinking levels are presented.
|
||||
*/
|
||||
export const KNOWN_THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh"] as const satisfies readonly ThinkingLevel[];
|
||||
|
||||
export function isKnownThinkingLevel(value: string): value is ThinkingLevel {
|
||||
return KNOWN_THINKING_LEVELS.some((level) => level === value);
|
||||
}
|
||||
|
||||
export function thinkingLevelLabel(level: string | undefined): string {
|
||||
return level === undefined || level === "" ? "off" : level;
|
||||
}
|
||||
|
||||
export interface ThinkingGauge {
|
||||
/** Number of bars to render (the non-"off" levels). */
|
||||
total: number;
|
||||
/** Number of filled bars for the current level. */
|
||||
filled: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Describe a thinking-level gauge from the available set rather than a hardcoded
|
||||
* table, so it stays correct even if pi changes the available levels at runtime.
|
||||
*
|
||||
* Convention: the first available level is treated as "no thinking". The gauge
|
||||
* therefore renders one bar per remaining level, and fills up to the current
|
||||
* level's rank. An unknown current level fills 0 bars instead of throwing.
|
||||
*/
|
||||
export function thinkingGauge(level: string | undefined, available: readonly string[]): ThinkingGauge {
|
||||
const pool = available.length >= 2 ? available : KNOWN_THINKING_LEVELS;
|
||||
const total = pool.length - 1;
|
||||
const normalized = thinkingLevelLabel(level);
|
||||
const index = pool.indexOf(normalized);
|
||||
const filled = index <= 0 ? 0 : Math.min(index, total);
|
||||
return { total, filled };
|
||||
}
|
||||
Reference in New Issue
Block a user