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`