diff --git a/.changeset/vscode-chat-fixes.md b/.changeset/vscode-chat-fixes.md new file mode 100644 index 00000000..1a2770a7 --- /dev/null +++ b/.changeset/vscode-chat-fixes.md @@ -0,0 +1,5 @@ +--- +"pythinker-code": patch +--- + +Render long conversations with a virtualized list, fix control overlap at narrow sidebar widths, and make sign-out work from the command palette. diff --git a/.changeset/vscode-composer-menu.md b/.changeset/vscode-composer-menu.md new file mode 100644 index 00000000..506f0608 --- /dev/null +++ b/.changeset/vscode-composer-menu.md @@ -0,0 +1,5 @@ +--- +"pythinker-code": minor +--- + +Combine permission mode, plan mode, and thinking effort into one composer menu, and answer approval prompts with number keys. diff --git a/.changeset/vscode-config-hub.md b/.changeset/vscode-config-hub.md new file mode 100644 index 00000000..1c0dea21 --- /dev/null +++ b/.changeset/vscode-config-hub.md @@ -0,0 +1,5 @@ +--- +"pythinker-code": minor +--- + +Add a config hub page that shows models, providers, MCP servers, the local config file, and extension settings in one place. diff --git a/.changeset/vscode-editor-theme.md b/.changeset/vscode-editor-theme.md new file mode 100644 index 00000000..c83bac39 --- /dev/null +++ b/.changeset/vscode-editor-theme.md @@ -0,0 +1,5 @@ +--- +"pythinker-code": minor +--- + +The extension UI now follows the editor color theme, including light, dark, and high-contrast themes. diff --git a/.changeset/vscode-host-integrations.md b/.changeset/vscode-host-integrations.md new file mode 100644 index 00000000..342e0f98 --- /dev/null +++ b/.changeset/vscode-host-integrations.md @@ -0,0 +1,5 @@ +--- +"pythinker-code": minor +--- + +Add a status bar indicator, quick-fix code actions, terminal and editor context menu entries, a getting-started walkthrough, and a new-conversation keybinding. diff --git a/apps/vscode/package.json b/apps/vscode/package.json index 550f5c80..d93fb9cc 100644 --- a/apps/vscode/package.json +++ b/apps/vscode/package.json @@ -122,6 +122,11 @@ "title": "Pythinker Code: Insert Current File", "icon": "$(mention)" }, + { + "command": "pythinker.addTerminalSelection", + "title": "Pythinker Code: Add Terminal Selection to Pythinker", + "icon": "$(terminal)" + }, { "command": "pythinker.newConversation", "title": "Pythinker Code: New Conversation", @@ -154,6 +159,64 @@ "key": "alt+k", "mac": "alt+k", "when": "editorTextFocus" + }, + { + "command": "pythinker.newConversation", + "key": "ctrl+alt+n", + "mac": "cmd+alt+n" + } + ], + "walkthroughs": [ + { + "id": "pythinkerGettingStarted", + "title": "Get started with Pythinker Code", + "description": "Set up Pythinker Code and run your first conversation.", + "steps": [ + { + "id": "openView", + "title": "Open the Pythinker view", + "description": "Select the Pythinker icon in the Activity Bar to open the chat view.\n[Open Pythinker](command:pythinker.webview.focus)", + "media": { + "markdown": "walkthrough/open-view.md" + }, + "completionEvents": [ + "onView:pythinker.webview" + ] + }, + { + "id": "signIn", + "title": "Sign in or add a provider", + "description": "Sign in with your Pythinker account, or add a model provider with your own API key.", + "media": { + "markdown": "walkthrough/sign-in.md" + }, + "completionEvents": [ + "onContext:pythinker.isLoggedIn" + ] + }, + { + "id": "firstConversation", + "title": "Run your first conversation", + "description": "Send a request and learn how the approval modes control what the agent can do.\n[New Conversation](command:pythinker.newConversation)", + "media": { + "markdown": "walkthrough/first-conversation.md" + }, + "completionEvents": [ + "onCommand:pythinker.newConversation" + ] + }, + { + "id": "referenceCode", + "title": "Reference your code", + "description": "Use @ mentions and Alt+K to point Pythinker at the exact files and lines you mean.", + "media": { + "markdown": "walkthrough/reference-code.md" + }, + "completionEvents": [ + "onCommand:pythinker.insertMention" + ] + } + ] } ], "viewsContainers": { @@ -208,6 +271,10 @@ "command": "pythinker.insertMention", "when": "true" }, + { + "command": "pythinker.addTerminalSelection", + "when": "false" + }, { "command": "pythinker.newConversation", "when": "true" @@ -228,8 +295,14 @@ "editor/context": [ { "command": "pythinker.insertMention", - "group": "pythoughts", - "when": "editorTextFocus" + "group": "pythinker@1", + "when": "editorHasSelection" + } + ], + "terminal/context": [ + { + "command": "pythinker.addTerminalSelection", + "group": "pythinker@1" } ] } @@ -259,12 +332,11 @@ "@types/node": "^22.15.3", "@types/react": "^19.1.2", "@types/react-dom": "^19.1.2", - "@types/react-scroll-to-bottom": "^4.2.5", "@types/react-syntax-highlighter": "^15.5.13", "@types/vscode": "1.100.0", "@vitejs/plugin-react": "^4.4.1", "@vscode/test-cli": "^0.0.11", - "@vscode/test-electron": "^2.5.2", + "@vscode/test-electron": "^3.1.0", "@vscode/vsce": "3.9.2", "acorn": "8.17.0", "ovsx": "1.0.2", @@ -275,7 +347,6 @@ }, "dependencies": { "@base-ui/react": "^1.0.0", - "@fontsource-variable/inter": "5.2.8", "@pythoughts/pythinker-code-sdk": "workspace:^", "@radix-ui/react-accordion": "^1.2.12", "@tabler/icons-react": "^3.36.0", @@ -293,8 +364,8 @@ "react": "^19.1.0", "react-dom": "^19.1.0", "react-markdown": "^10.1.0", - "react-scroll-to-bottom": "^4.2.0", "react-syntax-highlighter": "^16.1.0", + "react-virtuoso": "^4.18.11", "rehype-katex": "^7.0.1", "remark-gfm": "^4.0.1", "remark-math": "^6.0.0", diff --git a/apps/vscode/shared/bridge.ts b/apps/vscode/shared/bridge.ts index 2a6e896d..e042a6b4 100644 --- a/apps/vscode/shared/bridge.ts +++ b/apps/vscode/shared/bridge.ts @@ -19,6 +19,7 @@ export const Methods = { Login: "login", Logout: "logout", SaveConfig: "saveConfig", + GetConfigInfo: "getConfigInfo", GetExtensionConfig: "getExtensionConfig", SaveExtensionConfig: "saveExtensionConfig", OpenSettings: "openSettings", @@ -103,7 +104,6 @@ export const Events = { InsertMention: "insertMention", NewConversation: "newConversation", FileChangesUpdated: "fileChangesUpdated", - RollbackInput: "rollbackInput", } as const; const rpcMethods = new Set(Object.values(Methods)); @@ -142,6 +142,7 @@ function validateParams(method: RpcMethod, params: unknown): boolean { case Methods.CheckLoginStatus: case Methods.Login: case Methods.Logout: + case Methods.GetConfigInfo: case Methods.GetExtensionConfig: case Methods.OpenSettings: case Methods.OpenFolder: diff --git a/apps/vscode/shared/legacy-sdk.ts b/apps/vscode/shared/legacy-sdk.ts index 63de4074..e992ac3a 100644 --- a/apps/vscode/shared/legacy-sdk.ts +++ b/apps/vscode/shared/legacy-sdk.ts @@ -196,6 +196,8 @@ export interface ModelConfig { name: string; provider: string; capabilities: string[]; + /** Maximum context size in tokens, from the model alias config (maxContextSize). */ + contextWindow?: number; adaptive_thinking?: boolean; support_efforts?: string[]; default_effort?: string; diff --git a/apps/vscode/shared/types.ts b/apps/vscode/shared/types.ts index c9b86a30..491d04bd 100644 --- a/apps/vscode/shared/types.ts +++ b/apps/vscode/shared/types.ts @@ -36,6 +36,13 @@ export interface ExtensionConfig { version: string; } +/** The user's local config file, verbatim. Content is the raw on-disk text and may contain API keys. */ +export interface ConfigInfo { + path: string | null; + exists: boolean; + content: string | null; +} + export interface WorkspaceStatus { hasWorkspace: boolean; path?: string; diff --git a/apps/vscode/src/PythinkerWebviewProvider.ts b/apps/vscode/src/PythinkerWebviewProvider.ts index 94090bf5..f46afc1a 100644 --- a/apps/vscode/src/PythinkerWebviewProvider.ts +++ b/apps/vscode/src/PythinkerWebviewProvider.ts @@ -19,6 +19,8 @@ function getNonce(): string { export class PythinkerWebviewProvider implements vscode.WebviewViewProvider { private webviews = new Map(); private bridgeHandler: BridgeHandler; + private sidebarView: vscode.WebviewView | undefined; + private badgeCount = 0; constructor( private readonly extensionUri: vscode.Uri, @@ -51,13 +53,37 @@ export class PythinkerWebviewProvider implements vscode.WebviewViewProvider { resolveWebviewView(webviewView: vscode.WebviewView): void { const webviewId = `sidebar_${crypto.randomUUID()}`; this.setupWebview(webviewId, webviewView.webview); + this.sidebarView = webviewView; + this.applyBadge(); webviewView.onDidDispose(() => { void this.bridgeHandler.disposeView(webviewId); this.webviews.delete(webviewId); + if (this.sidebarView === webviewView) this.sidebarView = undefined; }); } + /** Shows `count` pending approval requests on the sidebar view; 0 clears the badge. */ + setSidebarBadge(count: number): void { + this.badgeCount = count; + this.applyBadge(); + } + + private applyBadge(): void { + const view = this.sidebarView; + if (view === undefined) return; + view.badge = + this.badgeCount > 0 + ? { + value: this.badgeCount, + tooltip: + this.badgeCount === 1 + ? "1 approval request waits for your decision" + : `${this.badgeCount} approval requests wait for your decision`, + } + : undefined; + } + createPanel(): vscode.WebviewPanel { const webviewId = `panel_${crypto.randomUUID()}`; diff --git a/apps/vscode/src/activity.ts b/apps/vscode/src/activity.ts new file mode 100644 index 00000000..425b6e99 --- /dev/null +++ b/apps/vscode/src/activity.ts @@ -0,0 +1,42 @@ +/** + * Host-side activity counters for the status bar and view badge. Streams are + * counted around the StreamChat handler; approvals and questions are counted + * in the reverse-RPC controller. No `vscode` import on purpose: the runtime + * files that call in here run under plain-node vitest. + */ + +export interface ActivityStatus { + readonly activeStreams: number; + readonly pendingApprovals: number; +} + +type ActivityListener = (status: ActivityStatus) => void; + +let activeStreams = 0; +let pendingApprovals = 0; +const listeners = new Set(); + +export function getActivity(): ActivityStatus { + return { activeStreams, pendingApprovals }; +} + +export function onActivityChange(listener: ActivityListener): () => void { + listeners.add(listener); + return () => listeners.delete(listener); +} + +export function trackStream(delta: number): void { + activeStreams = Math.max(0, activeStreams + delta); + fire(); +} + +export function trackApprovals(delta: number): void { + if (delta === 0) return; + pendingApprovals = Math.max(0, pendingApprovals + delta); + fire(); +} + +function fire(): void { + const status = getActivity(); + for (const listener of listeners) listener(status); +} diff --git a/apps/vscode/src/config/vscode-settings.ts b/apps/vscode/src/config/vscode-settings.ts index c0c14c53..31e200b1 100644 --- a/apps/vscode/src/config/vscode-settings.ts +++ b/apps/vscode/src/config/vscode-settings.ts @@ -27,7 +27,7 @@ export const VSCodeSettings = { }, get showThinkingContent(): boolean { - return getConfig().get("showThinkingContent", false); + return getConfig().get("showThinkingContent", true); }, get showThinkingExpanded(): boolean { diff --git a/apps/vscode/src/extension.ts b/apps/vscode/src/extension.ts index 088b564d..5680cdc6 100644 --- a/apps/vscode/src/extension.ts +++ b/apps/vscode/src/extension.ts @@ -2,7 +2,11 @@ import * as vscode from "vscode"; import { Events } from "../shared/bridge"; import { PythinkerWebviewProvider } from "./PythinkerWebviewProvider"; +import { getActivity, onActivityChange, type ActivityStatus } from "./activity"; import { onSettingsChange, VSCodeSettings } from "./config/vscode-settings"; +import { performLogout } from "./handlers/auth.handler"; +import { clearInputHistory } from "./handlers/workspace.handler"; +import { registerChatContext } from "./integrations/chat-context"; import { defaultPermissionMode } from "./runtime/permission-mode"; import { updateLoginContext } from "./utils/context"; @@ -56,11 +60,35 @@ export async function activate(context: vscode.ExtensionContext): Promise webviewOptions: { retainContextWhenHidden: true }, })); + const statusItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 100); + statusItem.name = "Pythinker Code"; + statusItem.command = "pythinker.webview.focus"; + const renderActivity = (activity: ActivityStatus): void => { + if (activity.pendingApprovals > 0) { + statusItem.text = "$(bell) Pythinker: approval needed"; + statusItem.tooltip = + activity.pendingApprovals === 1 + ? "1 request waits for your decision" + : `${activity.pendingApprovals} requests wait for your decision`; + statusItem.backgroundColor = new vscode.ThemeColor("statusBarItem.warningBackground"); + } else if (activity.activeStreams > 0) { + statusItem.text = "$(sync~spin) Pythinker"; + statusItem.tooltip = "Pythinker works on your request"; + statusItem.backgroundColor = undefined; + } else { + statusItem.text = "$(sparkle) Pythinker"; + statusItem.tooltip = "Open Pythinker Code"; + statusItem.backgroundColor = undefined; + } + statusItem.show(); + provider?.setSidebarBadge(activity.pendingApprovals); + }; + renderActivity(getActivity()); + context.subscriptions.push(statusItem, { dispose: onActivityChange(renderActivity) }); + const commands: Record void | Promise> = { "pythinker.clearAllState": async () => { - await context.globalState.update("pythinker.config", undefined); - await context.globalState.update("pythinker.mcpServers", undefined); - await context.workspaceState.update("pythinker.mcpEnabled", undefined); + await clearInputHistory(context.workspaceState); await vscode.window.showInformationMessage("Pythinker: Extension UI state cleared."); }, "pythinker.openInTab": () => { @@ -91,8 +119,17 @@ export async function activate(context: vscode.ExtensionContext): Promise "pythinker.showLogs": () => outputChannel?.show(), "pythinker.resetPythinker": () => provider?.resetAllWebviews(), "pythinker.logout": async () => { - await vscode.commands.executeCommand("pythinker.webview.focus"); - await vscode.window.showInformationMessage("Use the logout button in Pythinker settings."); + if (!provider) return; + const result = await performLogout(provider.harness, logError); + if (!result.success) { + await vscode.window.showErrorMessage( + `Pythinker: Sign out failed.${result.error ? ` ${result.error}` : ""}`, + ); + return; + } + // No login-state broadcast exists on the bridge; a reload re-runs the + // webview init, which re-checks the login status and provider list. + provider.reloadAllWebviews(); }, }; @@ -100,6 +137,15 @@ export async function activate(context: vscode.ExtensionContext): Promise context.subscriptions.push(vscode.commands.registerCommand(id, handler)); } + context.subscriptions.push( + ...registerChatContext({ + insertMention: (uri, selection) => + provider?.insertEditorMention(uri, selection) ?? Promise.resolve(false), + insertText: (text) => provider?.broadcast(Events.InsertMention, { mention: text }), + logError, + }), + ); + log("Pythinker Code activated"); } diff --git a/apps/vscode/src/handlers/auth.handler.ts b/apps/vscode/src/handlers/auth.handler.ts index d3f33693..4d158d55 100644 --- a/apps/vscode/src/handlers/auth.handler.ts +++ b/apps/vscode/src/handlers/auth.handler.ts @@ -1,4 +1,4 @@ -import { runLogin } from "@pythoughts/pythinker-code-sdk"; +import { runLogin, type PythinkerHarness } from "@pythoughts/pythinker-code-sdk"; import * as vscode from "vscode"; import { Methods } from "../../shared/bridge"; @@ -80,42 +80,53 @@ export const authHandlers: Record> = { }, [Methods.Logout]: async (_, ctx): Promise => { - try { - // Only providers a login created carry a `source` (catalog id, custom - // registry URL, or the OpenAI Codex OAuth marker). Hand-written - // `config.toml` entries have none and must survive a sign-out. One - // `replaceConfig` write so a failure cannot leave a half-signed-out - // config behind. - const config = await ctx.harness.getConfig({ reload: true }); - const providers = Object.fromEntries( - Object.entries(config.providers ?? {}).filter(([, provider]) => provider.source === undefined), - ); - const removed = new Set( - Object.keys(config.providers ?? {}).filter((id) => providers[id] === undefined), - ); - const models = Object.fromEntries( - Object.entries(config.models ?? {}).filter(([, alias]) => !removed.has(alias.provider)), - ); - await ctx.harness.replaceConfig({ - ...config, - providers, - models, - defaultModel: - config.defaultModel !== undefined && models[config.defaultModel] === undefined - ? undefined - : config.defaultModel, - }); - await updateLoginContext(ctx.harness); - return { success: true }; - } catch (error) { - ctx.logError("Pythinker logout failed", error); - await updateLoginContext(ctx.harness).catch((statusError: unknown) => { - ctx.logError("Unable to refresh login status after a failed logout", statusError); - }); - return { - success: false, - error: error instanceof Error ? error.message : String(error), - }; - } + return performLogout(ctx.harness, ctx.logError); }, }; + +/** + * The one logout path — shared by the webview bridge and the + * `pythinker.logout` command. + */ +export async function performLogout( + harness: PythinkerHarness, + logError: (message: string, error: unknown) => void, +): Promise { + try { + // Only providers a login created carry a `source` (catalog id, custom + // registry URL, or the OpenAI Codex OAuth marker). Hand-written + // `config.toml` entries have none and must survive a sign-out. One + // `replaceConfig` write so a failure cannot leave a half-signed-out + // config behind. + const config = await harness.getConfig({ reload: true }); + const providers = Object.fromEntries( + Object.entries(config.providers ?? {}).filter(([, provider]) => provider.source === undefined), + ); + const removed = new Set( + Object.keys(config.providers ?? {}).filter((id) => providers[id] === undefined), + ); + const models = Object.fromEntries( + Object.entries(config.models ?? {}).filter(([, alias]) => !removed.has(alias.provider)), + ); + await harness.replaceConfig({ + ...config, + providers, + models, + defaultModel: + config.defaultModel !== undefined && models[config.defaultModel] === undefined + ? undefined + : config.defaultModel, + }); + await updateLoginContext(harness); + return { success: true }; + } catch (error) { + logError("Pythinker logout failed", error); + await updateLoginContext(harness).catch((statusError: unknown) => { + logError("Unable to refresh login status after a failed logout", statusError); + }); + return { + success: false, + error: error instanceof Error ? error.message : String(error), + }; + } +} diff --git a/apps/vscode/src/handlers/chat.handler.ts b/apps/vscode/src/handlers/chat.handler.ts index a3d416e8..021c1344 100644 --- a/apps/vscode/src/handlers/chat.handler.ts +++ b/apps/vscode/src/handlers/chat.handler.ts @@ -5,6 +5,7 @@ import { Events, Methods } from "../../shared/bridge"; import type { ApprovalResponse, ContentPart } from "../../shared/legacy-sdk"; import { getUserMessage } from "../../shared/errors"; import type { ErrorPhase } from "../../shared/types"; +import { trackStream } from "../activity"; import { VSCodeSettings } from "../config/vscode-settings"; import { defaultPermissionMode } from "../runtime/permission-mode"; import { normalizeEffort } from "../runtime/pythinker-runtime"; @@ -212,7 +213,16 @@ const resetSession: Handler = async (_, ctx) => { }; export const chatHandlers: Record> = { - [Methods.StreamChat]: streamChat, + // The stream is active for exactly the lifetime of the StreamChat request: + // the handler resolves only when the turn reaches its terminal event. + [Methods.StreamChat]: async (params: StreamChatParams, ctx) => { + trackStream(1); + try { + return await streamChat(params, ctx); + } finally { + trackStream(-1); + } + }, [Methods.AbortChat]: abortChat, [Methods.RespondApproval]: respondApproval, [Methods.RespondQuestion]: respondQuestion, diff --git a/apps/vscode/src/handlers/config.handler.ts b/apps/vscode/src/handlers/config.handler.ts index 4804b2eb..63c71d85 100644 --- a/apps/vscode/src/handlers/config.handler.ts +++ b/apps/vscode/src/handlers/config.handler.ts @@ -1,3 +1,4 @@ +import { readFile } from "node:fs/promises"; import * as vscode from "vscode"; import { buildSkillSlashCommands, type SkillSlashCommand } from "@pythoughts/pythinker-code-sdk"; type SdkConfig = any; @@ -8,7 +9,7 @@ import type { ModelsConfig, SlashCommandInfo, } from "../../shared/legacy-sdk"; -import type { ExtensionConfig, SessionConfig } from "../../shared/types"; +import type { ConfigInfo, ExtensionConfig, SessionConfig } from "../../shared/types"; import { VSCodeSettings } from "../config/vscode-settings"; import { normalizeEffort } from "../runtime/pythinker-runtime"; import type { Handler } from "./types"; @@ -68,6 +69,23 @@ const saveConfig: Handler = async (params, ctx) return { ok: true }; }; +/** + * The raw config file, verbatim — deliberately NOT the parsed+redacted webview + * config. It is the user's own local file rendered in their own editor, so any + * API keys in it are shown as-is; nothing is redacted silently. + */ +const getConfigInfo: Handler = async (_, ctx) => { + const path = ctx.harness.configPath; + try { + return { path, exists: true, content: await readFile(path, "utf8") }; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return { path, exists: false, content: null }; + } + throw error; + } +}; + const getExtensionConfig: Handler = async () => { return VSCodeSettings.getExtensionConfig(); }; @@ -132,6 +150,7 @@ const reloadWebview: Handler = async (_, ctx) => { export const configHandlers = { [Methods.SaveConfig]: saveConfig, + [Methods.GetConfigInfo]: getConfigInfo, [Methods.GetExtensionConfig]: getExtensionConfig, [Methods.SaveExtensionConfig]: saveExtensionConfig, [Methods.OpenSettings]: openSettings, @@ -159,6 +178,7 @@ function toWebviewModel(id: string, model: any): ModelConfig { name: model.displayName ?? model.model ?? id, provider: model.provider ?? "unknown", capabilities: [...(model.capabilities ?? [])], + contextWindow: typeof model.maxContextSize === "number" ? model.maxContextSize : undefined, adaptive_thinking: model.adaptiveThinking, support_efforts: model.supportEfforts === undefined ? undefined : [...model.supportEfforts], diff --git a/apps/vscode/src/handlers/workspace.handler.ts b/apps/vscode/src/handlers/workspace.handler.ts index c11c8c91..dacb2529 100644 --- a/apps/vscode/src/handlers/workspace.handler.ts +++ b/apps/vscode/src/handlers/workspace.handler.ts @@ -19,6 +19,10 @@ const openFolder: Handler = async () => { return { ok: true }; }; +export async function clearInputHistory(workspaceState: vscode.Memento): Promise { + await workspaceState.update(INPUT_HISTORY_KEY, undefined); +} + const getInputHistory: Handler = async (_, ctx) => { return ctx.workspaceState.get(INPUT_HISTORY_KEY, []); }; diff --git a/apps/vscode/src/integrations/chat-context.ts b/apps/vscode/src/integrations/chat-context.ts new file mode 100644 index 00000000..69447244 --- /dev/null +++ b/apps/vscode/src/integrations/chat-context.ts @@ -0,0 +1,114 @@ +import * as vscode from "vscode"; + +/** + * Editor and terminal entry points that push context into the chat input. + * All inserts go through the existing insertMention flow — no new webview + * protocol. + */ +export interface ChatContextDeps { + /** Insert an @file mention into the open webviews; false when no webview can mention the file. */ + insertMention(documentUri: vscode.Uri, selection: vscode.Selection): Promise; + /** Append plain text to the chat input of the open webviews. */ + insertText(text: string): void; + logError(message: string, error: unknown): void; +} + +const OUTSIDE_WORKDIR_WARNING = "The active file is outside the selected working directory."; + +class PythinkerCodeActionProvider implements vscode.CodeActionProvider { + // Built at registration time: the VSIX audit imports this bundle with an + // empty `vscode` stub, so module-load code must not touch vscode members. + static metadata(): vscode.CodeActionProviderMetadata { + return { providedCodeActionKinds: [vscode.CodeActionKind.QuickFix] }; + } + + provideCodeActions( + document: vscode.TextDocument, + _range: vscode.Range, + context: vscode.CodeActionContext, + ): vscode.CodeAction[] { + const editor = vscode.window.activeTextEditor; + const selection = + editor?.document === document && !editor.selection.isEmpty ? editor.selection : undefined; + const diagnosticRange = context.diagnostics.reduce( + (union, diagnostic) => (union ? union.union(diagnostic.range) : diagnostic.range), + undefined, + ); + const target = selection ?? diagnosticRange; + if (!target) return []; + + const addAction = new vscode.CodeAction("Add to Pythinker", vscode.CodeActionKind.QuickFix); + addAction.command = { + command: "pythinker.addToChat", + title: "Add to Pythinker", + arguments: [document.uri, target], + }; + const actions = [addAction]; + + if (diagnosticRange) { + const fixAction = new vscode.CodeAction("Fix with Pythinker", vscode.CodeActionKind.QuickFix); + fixAction.command = { + command: "pythinker.fixWithPythinker", + title: "Fix with Pythinker", + arguments: [document.uri, diagnosticRange, [...context.diagnostics]], + }; + actions.push(fixAction); + } + return actions; + } +} + +export function registerChatContext(deps: ChatContextDeps): vscode.Disposable[] { + const insertMention = async (uri: vscode.Uri, range: vscode.Range): Promise => { + await vscode.commands.executeCommand("pythinker.webview.focus"); + if (!(await deps.insertMention(uri, new vscode.Selection(range.start, range.end)))) { + await vscode.window.showWarningMessage(OUTSIDE_WORKDIR_WARNING); + } + }; + + return [ + vscode.languages.registerCodeActionsProvider( + "*", + new PythinkerCodeActionProvider(), + PythinkerCodeActionProvider.metadata(), + ), + + vscode.commands.registerCommand( + "pythinker.addToChat", + (uri: vscode.Uri, range: vscode.Range) => insertMention(uri, range), + ), + + vscode.commands.registerCommand( + "pythinker.fixWithPythinker", + async (uri: vscode.Uri, range: vscode.Range, diagnostics: vscode.Diagnostic[]) => { + const problems = diagnostics.map((diagnostic) => diagnostic.message.trim()).join("; "); + await vscode.commands.executeCommand("pythinker.webview.focus"); + deps.insertText(`Fix these problems: ${problems}`); + await insertMention(uri, range); + }, + ), + + vscode.commands.registerCommand("pythinker.addTerminalSelection", async () => { + // Clipboard round-trip: workbench.action.terminal.copySelection is the + // only way to read the terminal selection, so save and restore the + // user's clipboard around it. A sentinel distinguishes "no selection" + // (copySelection is a no-op) from a selection that happens to equal the + // old clipboard content. + const previousClipboard = await vscode.env.clipboard.readText(); + const sentinel = `__pythinker_no_selection_${Date.now()}__`; + let selection = ""; + try { + await vscode.env.clipboard.writeText(sentinel); + await vscode.commands.executeCommand("workbench.action.terminal.copySelection"); + selection = (await vscode.env.clipboard.readText()).trim(); + } catch (error) { + deps.logError("Unable to read the terminal selection", error); + } finally { + await vscode.env.clipboard.writeText(previousClipboard); + } + if (!selection || selection === sentinel) return; + await vscode.commands.executeCommand("pythinker.webview.focus"); + deps.insertText(`Terminal output:\n\`\`\`\n${selection}\n\`\`\``); + }), + ]; +} diff --git a/apps/vscode/src/runtime/reverse-rpc.ts b/apps/vscode/src/runtime/reverse-rpc.ts index e49e8b19..73c21ccf 100644 --- a/apps/vscode/src/runtime/reverse-rpc.ts +++ b/apps/vscode/src/runtime/reverse-rpc.ts @@ -8,6 +8,7 @@ import type { } from "@pythoughts/pythinker-code-sdk"; import type { ApprovalResponse, QuestionRequest as LegacyQuestionRequest } from "../../shared/legacy-sdk"; +import { trackApprovals } from "../activity"; import { describeToolDisplay, toLegacyDisplay } from "./tool-display"; export type ReverseRpcEvent = @@ -24,6 +25,7 @@ export class ReverseRpcController { const id = randomUUID(); return new Promise((resolve) => { this.approvals.set(id, resolve); + trackApprovals(1); this.emit({ type: "ApprovalRequest", payload: approvalPayload(id, request) }); }); } @@ -32,6 +34,7 @@ export class ReverseRpcController { const id = randomUUID(); return new Promise((resolve) => { this.questions.set(id, resolve); + trackApprovals(1); this.emit({ type: "QuestionRequest", payload: { @@ -55,6 +58,7 @@ export class ReverseRpcController { const resolve = this.approvals.get(id); if (!resolve) return false; this.approvals.delete(id); + trackApprovals(-1); if (response === "approve_for_session") { resolve({ decision: "approved", scope: "session" }); } else if (response === "approve") { @@ -69,11 +73,13 @@ export class ReverseRpcController { const resolve = this.questions.get(id); if (!resolve) return false; this.questions.delete(id); + trackApprovals(-1); resolve({ answers }); return true; } cancelAll(reason: string): void { + trackApprovals(-(this.approvals.size + this.questions.size)); for (const resolve of this.approvals.values()) { resolve({ decision: "cancelled", feedback: reason }); } diff --git a/apps/vscode/walkthrough/first-conversation.md b/apps/vscode/walkthrough/first-conversation.md new file mode 100644 index 00000000..039c2ab3 --- /dev/null +++ b/apps/vscode/walkthrough/first-conversation.md @@ -0,0 +1,17 @@ +# Run your first conversation + +1. Open a folder in VS Code. Pythinker works inside your workspace. +2. Type a request in the chat input, for example: "Explain this repository." +3. Press `Enter` to send it. + +Start a fresh conversation at any time with `Ctrl+Alt+N` (`Cmd+Alt+N` on Mac). + +## Approval modes + +Pythinker asks before it runs actions that change your files or your system. Three modes control this: + +- **Manual** — Pythinker asks you to approve each sensitive action. This is the default. +- **Auto** — Pythinker approves tool calls and dismisses questions automatically. +- **YOLO** — Pythinker approves regular tool calls automatically, but can still ask you questions. + +Type `/auto` or `/yolo` in the chat to change the mode. When a request waits for your decision, the status bar shows a bell and the Pythinker view shows a badge. diff --git a/apps/vscode/walkthrough/open-view.md b/apps/vscode/walkthrough/open-view.md new file mode 100644 index 00000000..957f4fa1 --- /dev/null +++ b/apps/vscode/walkthrough/open-view.md @@ -0,0 +1,10 @@ +# Open the Pythinker view + +Select the Pythinker icon in the Activity Bar to open the chat view. + +Other ways to get there: + +- Press `Ctrl+Shift+K` (`Cmd+Shift+K` on Mac) to focus the chat input from anywhere. +- Run **Pythinker Code: Open in New Tab** to use Pythinker in a full editor tab. + +The status bar shows a Pythinker item on the right. Click it to open the view at any time. diff --git a/apps/vscode/walkthrough/reference-code.md b/apps/vscode/walkthrough/reference-code.md new file mode 100644 index 00000000..a059c3fd --- /dev/null +++ b/apps/vscode/walkthrough/reference-code.md @@ -0,0 +1,9 @@ +# Reference your code + +Give Pythinker the exact files and lines you mean: + +- Type `@` in the chat input to search the workspace and attach a file to your message. +- Put the cursor in an editor (or select some lines) and press `Alt+K`. Pythinker inserts a mention of that file into the chat input. +- A selection adds the line range to the mention, so Pythinker reads exactly the code you selected. + +You can also right-click in an editor and select **Pythinker Code: Insert Current File**. diff --git a/apps/vscode/walkthrough/sign-in.md b/apps/vscode/walkthrough/sign-in.md new file mode 100644 index 00000000..1ef22673 --- /dev/null +++ b/apps/vscode/walkthrough/sign-in.md @@ -0,0 +1,10 @@ +# Sign in or add a provider + +Pythinker Code can talk to many model providers. Set up one of these: + +- **Pythinker account** — select **Sign in** in the chat view. A browser window opens to complete the sign-in. +- **Your own API key** — open the settings menu in the chat view and add a provider from the catalog. Enter your API key for that provider. + +You do this once. Pythinker stores the configuration and uses it for all later sessions. + +To sign out later, run **Pythinker Code: Logout** from the Command Palette. diff --git a/apps/vscode/webview-ui/src/App.tsx b/apps/vscode/webview-ui/src/App.tsx index f066d5f0..0531e538 100644 --- a/apps/vscode/webview-ui/src/App.tsx +++ b/apps/vscode/webview-ui/src/App.tsx @@ -3,10 +3,8 @@ import { useEffect, useState, useCallback } from "react"; import { Header } from "./components/Header"; import { ChatArea } from "./components/ChatArea"; import { InputArea } from "./components/inputarea/InputArea"; -import { MCPServersModal } from "./components/MCPServersModal"; -import { ProvidersModal } from "./components/ProvidersModal"; import { WorkDirModal } from "./components/WorkDirModal"; -import { SettingsDialog } from "./components/SettingsDialog"; +import { ConfigHub } from "./components/confighub/ConfigHub"; import { ConfigErrorScreen } from "./components/ConfigErrorScreen"; import { LoginScreen } from "./components/LoginScreen"; import { Toaster, toast } from "./components/ui/sonner"; @@ -19,13 +17,12 @@ import "./styles/index.css"; function MainContent({ onAuthAction }: { onAuthAction: () => void }) { const { processEvent, startNewConversation, sessionId } = useChatStore(); - const { setMCPServers, setExtensionConfig, extensionConfig, setWireSlashCommands } = useSettingsStore(); + const { setMCPServers, setExtensionConfig, extensionConfig, setWireSlashCommands, configHub } = useSettingsStore(); useEffect(() => { return bridge.on(Events.StreamEvent, (event: UIStreamEvent) => { // Filter only when a session already exists to ensure session_start is handled properly if (sessionId && "_sessionId" in event && event._sessionId && event._sessionId !== sessionId) { - console.log("Ignored stream event from another session:", event._sessionId); return; } processEvent(event); @@ -69,16 +66,19 @@ function MainContent({ onAuthAction }: { onAuthAction: () => void }) { return ( <> -
- -
-
- -
- - + {configHub.open ? ( + + ) : ( + <> +
+ +
+
+ +
+ + )} - ); } diff --git a/apps/vscode/webview-ui/src/components/ActionMenu.tsx b/apps/vscode/webview-ui/src/components/ActionMenu.tsx index 23c02ae7..fe0586a6 100644 --- a/apps/vscode/webview-ui/src/components/ActionMenu.tsx +++ b/apps/vscode/webview-ui/src/components/ActionMenu.tsx @@ -33,7 +33,7 @@ function MenuItem({ onClick, disabled, danger, children }: { onClick: () => void className={cn( "w-full flex items-center gap-2 px-2.5 py-1.5 text-xs hover:bg-accent transition-colors text-left cursor-pointer", disabled && "opacity-50 cursor-not-allowed", - danger && "text-red-500 hover:text-red-600", + danger && "text-destructive hover:text-destructive/80", )} > {children} @@ -44,7 +44,7 @@ function MenuItem({ onClick, disabled, danger, children }: { onClick: () => void export function ActionMenu({ className, onAuthAction }: ActionMenuProps) { const [open, setOpen] = useState(false); const [loading, setLoading] = useState(false); - const { setMCPModalOpen, setProvidersModalOpen, isLoggedIn, setIsLoggedIn, extensionConfig } = useSettingsStore(); + const { openConfigHub, isLoggedIn, setIsLoggedIn, extensionConfig } = useSettingsStore(); const handleOpenSettings = () => { void bridge.openSettings(); @@ -52,12 +52,12 @@ export function ActionMenu({ className, onAuthAction }: ActionMenuProps) { }; const handleOpenProviders = () => { - setProvidersModalOpen(true); + openConfigHub("providers"); setOpen(false); }; const handleOpenMCPServers = () => { - setMCPModalOpen(true); + openConfigHub("mcp"); setOpen(false); }; diff --git a/apps/vscode/webview-ui/src/components/ApprovalDialog.tsx b/apps/vscode/webview-ui/src/components/ApprovalDialog.tsx index 6c2d8773..955d7d82 100644 --- a/apps/vscode/webview-ui/src/components/ApprovalDialog.tsx +++ b/apps/vscode/webview-ui/src/components/ApprovalDialog.tsx @@ -1,22 +1,29 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect, useRef } from "react"; import { IconChevronDown, IconChevronUp } from "@tabler/icons-react"; import { useApprovalStore } from "@/stores"; import { DisplayBlocks } from "./DisplayBlocks"; import { cn } from "@/lib/utils"; import type { ApprovalResponse } from "shared/legacy-sdk"; +const focusComposer = () => document.querySelector("textarea")?.focus(); + export function ApprovalDialog() { const { pending, respondToRequest } = useApprovalStore(); const [selectedIndex, setSelectedIndex] = useState(1); const [expanded, setExpanded] = useState(false); + const cardRef = useRef(null); + // The request stays in the store until the RPC settles, so repeated key + // presses would send duplicate responses without this guard. + const inFlightRef = useRef(false); const req = pending[0]; - // Auto-expand if there's a diff block (code change) + // Auto-expand if there's a diff block (code change); focus the card so number shortcuts work. useEffect(() => { if (req) { const hasDiff = req.display?.some((b) => b.type === "diff") ?? false; setExpanded(hasDiff); + cardRef.current?.focus(); } }, [req?.id]); @@ -24,9 +31,16 @@ export function ApprovalDialog() { const hasDisplay = req.display && req.display.length > 0; const handleResponse = async (response: ApprovalResponse) => { - await respondToRequest(req.id, response); - setSelectedIndex(1); - setExpanded(false); + if (inFlightRef.current) return; + inFlightRef.current = true; + try { + await respondToRequest(req.id, response); + setSelectedIndex(1); + setExpanded(false); + focusComposer(); + } finally { + inFlightRef.current = false; + } }; const options = [ @@ -35,11 +49,43 @@ export function ApprovalDialog() { { key: "reject", label: "No", index: 3 }, ] as const; + const handleKeyDown = (e: React.KeyboardEvent) => { + const digit = Number(e.key); + if (digit >= 1 && digit <= options.length) { + e.preventDefault(); + const opt = options[digit - 1]; + if (opt) void handleResponse(opt.key); + } else if (e.key === "ArrowDown") { + e.preventDefault(); + setSelectedIndex((i) => Math.min(i + 1, options.length)); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + setSelectedIndex((i) => Math.max(i - 1, 1)); + } else if (e.key === "Enter") { + e.preventDefault(); + const opt = options[selectedIndex - 1]; + if (opt) void handleResponse(opt.key); + } else if (e.key === "Escape") { + e.preventDefault(); + focusComposer(); + } + }; + return ( -
+
-
Allow this {req.action.toLowerCase()}?
+
+
Allow this {req.action.toLowerCase()}?
+ {pending.length > 1 && ( + +{pending.length - 1} more pending + )} +
{hasDisplay && ( ))} diff --git a/apps/vscode/webview-ui/src/components/BottomToolbar.tsx b/apps/vscode/webview-ui/src/components/BottomToolbar.tsx index dde016c6..5ea27eac 100644 --- a/apps/vscode/webview-ui/src/components/BottomToolbar.tsx +++ b/apps/vscode/webview-ui/src/components/BottomToolbar.tsx @@ -83,7 +83,7 @@ export function BottomToolbar() { {fileChanges.length} Changed - +{fileStats.additions} -{fileStats.deletions} + +{fileStats.additions} -{fileStats.deletions} diff --git a/apps/vscode/webview-ui/src/components/BrailleSpinner.tsx b/apps/vscode/webview-ui/src/components/BrailleSpinner.tsx deleted file mode 100644 index 39cb958c..00000000 --- a/apps/vscode/webview-ui/src/components/BrailleSpinner.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { useState, useEffect } from "react"; - -const BRAILLE_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; - -export function BrailleSpinner({ className }: { className?: string }) { - const [frameIndex, setFrameIndex] = useState(0); - - useEffect(() => { - const timer = setInterval(() => { - setFrameIndex((prev) => (prev + 1) % BRAILLE_FRAMES.length); - }, 80); - - return () => clearInterval(timer); - }, []); - - return ( - - ); -} diff --git a/apps/vscode/webview-ui/src/components/ChatArea.tsx b/apps/vscode/webview-ui/src/components/ChatArea.tsx index 5d4f8e88..72f3da73 100644 --- a/apps/vscode/webview-ui/src/components/ChatArea.tsx +++ b/apps/vscode/webview-ui/src/components/ChatArea.tsx @@ -1,4 +1,5 @@ -import ScrollToBottom, { useScrollToBottom, useSticky } from "react-scroll-to-bottom"; +import { useRef, useState } from "react"; +import { Virtuoso, type VirtuosoHandle } from "react-virtuoso"; import { IconArrowDown } from "@tabler/icons-react"; import { ChatMessage } from "./ChatMessage"; import { WelcomeScreen } from "./WelcomeScreen"; @@ -6,47 +7,25 @@ import { useChatStore } from "@/stores"; import { cn } from "@/lib/utils"; import { getForkTurnIndex } from "shared/fork-turn-index"; -function ScrollButton() { - const scrollToBottom = useScrollToBottom(); - const [sticky] = useSticky(); - - if (sticky) return null; - +function ScrollButton({ onClick }: { onClick: () => void }) { return ( ); } -function MessageList() { +export function ChatArea() { const messages = useChatStore((s) => s.messages); const isStreaming = useChatStore((s) => s.isStreaming); + const sessionId = useChatStore((s) => s.sessionId); + const virtuosoRef = useRef(null); + const [atBottom, setAtBottom] = useState(true); - return ( - <> -
- {messages.map((message, idx) => ( - - ))} -
- - - ); -} - -export function ChatArea() { - const messageCount = useChatStore((s) => s.messages.length); - - if (messageCount === 0) { + if (messages.length === 0) { return (
@@ -56,9 +35,41 @@ export function ChatArea() { return (
- - - + message.id} + initialTopMostItemIndex={messages.length - 1} + // "auto" sticks to the bottom on new rows only while already there; + // scrolling up breaks the pin, returning to the bottom restores it. + // Virtuoso also re-pins when the streaming tail grows in place. + followOutput="auto" + atBottomStateChange={setAtBottom} + atBottomThreshold={10} + // Pre-render a few hundred px above the viewport; keep everything below + // it mounted so the streaming tail never unmounts mid-stream (Cline's + // always-rendered-tail trick — cheap because below-viewport content is + // just the tail while pinned to the bottom). + increaseViewportBy={{ top: 400, bottom: Number.MAX_SAFE_INTEGER }} + // Virtuoso manages scroll position itself; native anchoring fights it. + style={{ overflowAnchor: "none" }} + itemContent={(idx, message) => ( + + )} + /> + {!atBottom && ( + virtuosoRef.current?.scrollToIndex({ index: messages.length - 1, align: "end", behavior: "smooth" })} + /> + )}
); } diff --git a/apps/vscode/webview-ui/src/components/ChatMessage.tsx b/apps/vscode/webview-ui/src/components/ChatMessage.tsx index 67534678..b02bf732 100644 --- a/apps/vscode/webview-ui/src/components/ChatMessage.tsx +++ b/apps/vscode/webview-ui/src/components/ChatMessage.tsx @@ -1,5 +1,5 @@ import { useState, Fragment, memo, type ReactNode } from "react"; -import { IconGitFork, IconBolt } from "@tabler/icons-react"; +import { IconGitFork } from "@tabler/icons-react"; import { cn } from "@/lib/utils"; import { Content } from "@/lib/content"; import { Markdown } from "./Markdown"; @@ -12,7 +12,6 @@ import { MediaPreviewModal } from "./MediaPreviewModal"; import { InlineError } from "./InlineError"; import { PlanCard } from "./PlanCard"; import { PythinkerLogo } from "./PythinkerLogo"; -import { useTokenSpeed } from "./ChatStatus"; import { StreamingConfirmDialog } from "./StreamingConfirmDialog"; import { Button } from "@/components/ui/button"; import { toast } from "@/components/ui/sonner"; @@ -29,13 +28,11 @@ interface ChatMessageProps { } function ThinkingIndicator() { - const { speed } = useTokenSpeed(); - return (
- + Pythinking . @@ -44,12 +41,6 @@ function ThinkingIndicator() {
- {speed > 0 && ( - - - {speed.toFixed(1)} t/s - - )}
); } @@ -58,7 +49,7 @@ function SteerBubble({ content }: { content: string | ContentPart[] }) { const text = typeof content === "string" ? content : Content.getText(content); return (
-
+

{text}

@@ -110,13 +101,13 @@ function StepContent({ step, showConnector, showLogo }:{ step: UIStep; showConne {showIndicator ? (
{showConnector && (
@@ -223,7 +214,7 @@ function ForkButton({ turnIndex, className }: ForkButtonProps) { variant="ghost" size="icon-xs" className={cn( - "h-5 w-5 text-muted-foreground hover:text-foreground transition-all border-0! hover:bg-zinc-200 dark:hover:bg-zinc-800 cursor-pointer", + "h-5 w-5 text-muted-foreground hover:text-foreground transition-all border-0! hover:bg-toolbar-hover cursor-pointer", isForking && "opacity-50 pointer-events-none", className, )} @@ -260,20 +251,22 @@ function UserMessage({ message }: { message: ChatMessageType }) { const videos = Content.getVideos(message.content); return ( -
-
- {displayContent && ( - // FIX: removed whitespace-pre-wrap — it conflicted with ReactMarkdown's - // block-level elements (

,

    ,
  1. ), doubling vertical spacing. - // ReactMarkdown already handles paragraph breaks from \n\n. -
    - -
    - )} - + <> +
    +
    + {displayContent && ( + // FIX: removed whitespace-pre-wrap — it conflicted with ReactMarkdown's + // block-level elements (

    ,

      ,
    1. ), doubling vertical spacing. + // ReactMarkdown already handles paragraph breaks from \n\n. +
      + +
      + )} + +
    setPreviewMedia(null)} /> -
+ ); } @@ -311,67 +304,69 @@ function AssistantMessage({ message, turnIndex, isStreaming }: { message: ChatMe const isShowingInlineError = message.inlineError && !isStreaming; return ( -
-
-
-
-
- {hasSteps && - groupStepsByPlanMode(steps).map((group, gi) => { - const totalSteps = steps.length; - // The logo identifies the assistant on its first reply, but only when the - // message draws no timeline: sitting in the gutter, it would cut the - // connector running between the step markers. - const hasTimeline = stepHasIndicator.filter(Boolean).length > 1; - const firstTextStepIndex = hasTimeline ? -1 : steps.findIndex((s) => s.items.some((item) => item.type === "text")); - const stepsContent = group.steps.map((step, i) => { - const globalIndex = group.startIndex + i; - const isLastInGroup = i === group.steps.length - 1; - const isLastOverall = globalIndex === totalSteps - 1; - const hasIndicator = stepHasIndicator[globalIndex]; - const hasNextIndicator = stepHasIndicator.slice(globalIndex + 1).some(Boolean); - const showConnector = hasIndicator && hasNextIndicator && !isLastInGroup && !isLastOverall; - return ; - }); + <> +
+
+
+
+
+ {hasSteps && + groupStepsByPlanMode(steps).map((group, gi) => { + const totalSteps = steps.length; + // The logo identifies the assistant on its first reply, but only when the + // message draws no timeline: sitting in the gutter, it would cut the + // connector running between the step markers. + const hasTimeline = stepHasIndicator.filter(Boolean).length > 1; + const firstTextStepIndex = hasTimeline ? -1 : steps.findIndex((s) => s.items.some((item) => item.type === "text")); + const stepsContent = group.steps.map((step, i) => { + const globalIndex = group.startIndex + i; + const isLastInGroup = i === group.steps.length - 1; + const isLastOverall = globalIndex === totalSteps - 1; + const hasIndicator = stepHasIndicator[globalIndex]; + const hasNextIndicator = stepHasIndicator.slice(globalIndex + 1).some(Boolean); + const showConnector = hasIndicator && hasNextIndicator && !isLastInGroup && !isLastOverall; + return ; + }); + + if (group.planMode) { + return {stepsContent}; + } + return {stepsContent}; + })} + {!hasSteps && displayContent && ( + + + + )} + {(images.length > 0 || videos.length > 0) && ( +
+ +
+ )} +
- if (group.planMode) { - return {stepsContent}; - } - return {stepsContent}; - })} - {!hasSteps && displayContent && ( - - - - )} - {(images.length > 0 || videos.length > 0) && ( + {/* Inline error display */} + {isShowingInlineError && message.inlineError && (
- +
)} -
- - {/* Inline error display */} - {isShowingInlineError && message.inlineError && ( -
- +
+
{isStreaming && !isShowingInlineError && !isCompacting && }
+
+ {!isStreaming && contentToCopy.trim().length > 0 && ( +
+ + {message.forkable !== false && turnIndex !== undefined && turnIndex >= 0 && } +
+ )}
- )} -
-
{isStreaming && !isShowingInlineError && !isCompacting && }
-
- {!isStreaming && contentToCopy.trim().length > 0 && ( -
- - {message.forkable !== false && turnIndex !== undefined && turnIndex >= 0 && } -
- )}
setPreviewMedia(null)} /> -
+ ); } diff --git a/apps/vscode/webview-ui/src/components/ChatStatus.tsx b/apps/vscode/webview-ui/src/components/ChatStatus.tsx index a363c1eb..4b438676 100644 --- a/apps/vscode/webview-ui/src/components/ChatStatus.tsx +++ b/apps/vscode/webview-ui/src/components/ChatStatus.tsx @@ -58,6 +58,14 @@ function subscribeToSpeed(listener: () => void): () => void { return () => speedListeners.delete(listener); } +/** Compact token count in Cline's style: 999, 12.3k, 1.2m. */ +function formatTokens(count: number): string { + // 999_950+ rounds to 1.0m; without this the k branch prints "1000.0k". + if (count >= 999_950) return `${(count / 1e6).toFixed(1)}m`; + if (count >= 1e3) return `${(count / 1e3).toFixed(1)}k`; + return String(count); +} + export function useTokenSpeed() { const isStreaming = useChatStore((s) => s.isStreaming); const outputTokens = useChatStore((s) => s.tokenUsage.output + s.activeTokenUsage.output); @@ -107,7 +115,7 @@ export function TokenInfo() { Context Window - 80 && "text-amber-500", contextPercent > 95 && "text-destructive")}> + 80 && "text-warning", contextPercent > 95 && "text-destructive")}> {contextPercent}% @@ -117,7 +125,7 @@ export function TokenInfo() {
- Generation Speed + Generation Speed {speed > 0 ? `${speed.toFixed(1)} tok/s` : "Idle"} @@ -150,6 +158,7 @@ export function TokenInfo() { export function ChatStatus() { const { lastStatus, tokenUsage, activeTokenUsage } = useChatStore(); + const { currentModel, models } = useSettingsStore(); const { speed, isStreaming } = useTokenSpeed(); if (!lastStatus && !isStreaming) { @@ -168,14 +177,24 @@ export function ChatStatus() { activeTokenUsage.input_cache_creation; const outputTotal = tokenUsage.output + activeTokenUsage.output; + const cacheRead = tokenUsage.input_cache_read + activeTokenUsage.input_cache_read; + const cacheWrite = tokenUsage.input_cache_creation + activeTokenUsage.input_cache_creation; + const hasUsage = inputTotal + outputTotal > 0; const contextPercent = context_usage ? Math.round(context_usage * 1000) / 10 : 0; + // ponytail: the bar reuses the engine-reported context_usage fraction so it + // never competes with the percent shown next to it; contextWindow only + // supplies the tooltip's absolute numbers. + const contextWindow = models.find((m) => m.id === currentModel)?.contextWindow; + const contextUsedTokens = contextWindow !== undefined && context_usage + ? Math.round(context_usage * contextWindow) + : undefined; return (
{retrying && ( - + Retry {retrying.next_attempt}/{retrying.max_attempts} @@ -191,8 +210,8 @@ export function ChatStatus() { {speed > 0 && ( - - + + {/* tabular-nums keeps the pill from resizing as the rate changes, and nowrap stops "74.7" and "t/s" breaking onto two lines in a narrow sidebar. */} @@ -209,46 +228,79 @@ export function ChatStatus() {
- + - 70 && "text-amber-500", contextPercent > 90 && "text-destructive font-semibold")}> + 70 && "text-warning", contextPercent > 90 && "text-destructive font-semibold")}> {contextPercent}% + {/* Cline-style context bar: 2px track, fill turns warning above 80%. */} + + 80 ? "bg-warning" : "bg-brand")} + style={{ width: `${Math.min(contextPercent, 100)}%` }} + /> + - Context Window Usage ({contextPercent}%) + +
+
+ Context Window Usage ({contextPercent}%) + {contextWindow !== undefined && ( + <> + {contextUsedTokens !== undefined && <> — {formatTokens(contextUsedTokens)}} of{" "} + {formatTokens(contextWindow)} tokens + + )} +
+ {hasUsage && ( +
+ Input {formatTokens(inputTotal)} (cache read {formatTokens(cacheRead)}, cache write{" "} + {formatTokens(cacheWrite)}) +
+ )} +
+
-
+ {/* Session token usage — only shown when the session has used tokens. */} + {hasUsage && ( + <> +
- {/* Input Tokens */} -
- - - - - {inputTotal.toLocaleString()} - - - Total Input Tokens - -
+ {/* Input Tokens */} +
+ + + + + {formatTokens(inputTotal)} + + + + Input {inputTotal.toLocaleString()} tokens (cache read {cacheRead.toLocaleString()}, cache write{" "} + {cacheWrite.toLocaleString()}) + + +
-
+
- {/* Output Tokens */} -
- - - - - {outputTotal.toLocaleString()} - - - Total Output Tokens - -
+ {/* Output Tokens */} +
+ + + + + {formatTokens(outputTotal)} + + + Output {outputTotal.toLocaleString()} tokens + +
+ + )}
); } diff --git a/apps/vscode/webview-ui/src/components/CompactionCard.tsx b/apps/vscode/webview-ui/src/components/CompactionCard.tsx index 43e6d85e..25647744 100644 --- a/apps/vscode/webview-ui/src/components/CompactionCard.tsx +++ b/apps/vscode/webview-ui/src/components/CompactionCard.tsx @@ -8,10 +8,10 @@ export function CompactionCard() {
{isCompacting ? ( - + ) : (
-
+
)}
diff --git a/apps/vscode/webview-ui/src/components/ComposerModeMenu.tsx b/apps/vscode/webview-ui/src/components/ComposerModeMenu.tsx new file mode 100644 index 00000000..3a5645e7 --- /dev/null +++ b/apps/vscode/webview-ui/src/components/ComposerModeMenu.tsx @@ -0,0 +1,203 @@ +import { useState } from "react"; +import { IconAdjustmentsHorizontal, IconBolt, IconBulb, IconCheck, IconClipboardList, IconRobot, IconShieldCheck } from "@tabler/icons-react"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { applyPermissionCommand, type PermissionCommand } from "@/stores/chat.store"; +import { cn } from "@/lib/utils"; +import type { PermissionMode, ThinkingMode } from "shared/legacy-sdk"; + +interface ModeEntry { + id: PermissionMode; + label: string; + description: string; + icon: typeof IconShieldCheck; +} + +/** Descriptions match the host's `/yolo` and `/auto` command help. */ +const MODES: readonly ModeEntry[] = [ + { id: "manual", label: "Manual", description: "Approve every action.", icon: IconShieldCheck }, + { id: "yolo", label: "YOLO", description: "Auto-approve tools; the agent may still ask questions.", icon: IconBolt }, + { id: "auto", label: "Auto", description: "Fully autonomous; the agent will not ask questions.", icon: IconRobot }, +]; + +/** Same path as the `/yolo` / `/auto` slash commands, so the flush-pending-approvals rule lives in one place. */ +function applyMode(current: PermissionMode, next: PermissionMode): Promise { + const command: PermissionCommand = + next === "manual" ? { mode: current === "auto" ? "auto" : "yolo", request: "off" } : { mode: next, request: "on" }; + return applyPermissionCommand(command); +} + +function effortLabel(effort: string): string { + return effort.charAt(0).toUpperCase() + effort.slice(1); +} + +function SectionLabel({ children }: { children: string }) { + return
{children}
; +} + +interface RowProps { + icon: typeof IconShieldCheck; + label: string; + description?: string; + checked: boolean; + warning?: boolean; + disabled?: boolean; + onSelect: () => void; +} + +function Row({ icon: Icon, label, description, checked, warning, disabled, onSelect }: RowProps) { + return ( + + ); +} + +interface ComposerModeMenuProps { + permissionMode: PermissionMode; + planMode: boolean; + onTogglePlanMode: () => void; + thinkingMode: ThinkingMode; + thinkingEffort: string; + thinkingEfforts?: string[]; + thinkingAlwaysOn?: boolean; + /** Streaming: thinking cannot change mid-turn; permission and plan still can. */ + thinkingDisabled?: boolean; + /** Muted wrapping note below the effort options (mid-conversation cache-cost notice). */ + cacheNote?: string; + onToggleThinking: () => void; + onSelectThinkingEffort: (effort: string) => void; +} + +/** + * Single composer control for permission mode, plan mode, and thinking + * effort. The store's `permissionMode` updates through host status events, + * so selection only fires the RPC — no optimistic state. + */ +export function ComposerModeMenu({ + permissionMode, + planMode, + onTogglePlanMode, + thinkingMode, + thinkingEffort, + thinkingEfforts = [], + thinkingAlwaysOn = false, + thinkingDisabled = false, + cacheNote, + onToggleThinking, + onSelectThinkingEffort, +}: ComposerModeMenuProps) { + const [open, setOpen] = useState(false); + const permission = MODES.find((entry) => entry.id === permissionMode) ?? MODES[0]!; + const thinkingActive = thinkingEffort !== "off" || thinkingAlwaysOn; + + const selectPermission = (next: PermissionMode) => { + setOpen(false); + if (next === permissionMode) return; + void applyMode(permissionMode, next); + }; + + const stateSummary = [ + permission.label, + planMode ? "Plan" : undefined, + thinkingMode !== "none" && thinkingActive ? `Thinking ${effortLabel(thinkingEffort)}` : undefined, + ] + .filter((part) => part !== undefined) + .join(" · "); + + return ( + + + + + + Permissions + {MODES.map((entry) => ( + selectPermission(entry.id)} + /> + ))} +
+ { + setOpen(false); + onTogglePlanMode(); + }} + /> + {thinkingMode !== "none" && ( + <> +
+ Thinking + {thinkingMode === "always" && {}} />} + {thinkingMode === "switch" && ( + { + setOpen(false); + onToggleThinking(); + }} + /> + )} + {thinkingMode === "effort" && + (thinkingAlwaysOn ? thinkingEfforts : ["off", ...thinkingEfforts]).map((option) => ( + { + setOpen(false); + onSelectThinkingEffort(option); + }} + /> + ))} + {cacheNote !== undefined && ( +
{cacheNote}
+ )} + + )} + + + ); +} diff --git a/apps/vscode/webview-ui/src/components/ConfigErrorScreen.tsx b/apps/vscode/webview-ui/src/components/ConfigErrorScreen.tsx index 8df33962..7b58ada0 100644 --- a/apps/vscode/webview-ui/src/components/ConfigErrorScreen.tsx +++ b/apps/vscode/webview-ui/src/components/ConfigErrorScreen.tsx @@ -61,7 +61,7 @@ function NoModelsContent({ onRefresh, onBackToLogin }: Pick
-
+
Model setup required
@@ -119,7 +119,7 @@ export function ConfigErrorScreen({ type, errorMessage, onRefresh, onBackToLogin
-
+
No workspace open
@@ -155,7 +155,7 @@ export function ConfigErrorScreen({ type, errorMessage, onRefresh, onBackToLogin
-
+
Pythinker Code could not start
diff --git a/apps/vscode/webview-ui/src/components/CopyButton.tsx b/apps/vscode/webview-ui/src/components/CopyButton.tsx index ab444649..9b9a5d9d 100644 --- a/apps/vscode/webview-ui/src/components/CopyButton.tsx +++ b/apps/vscode/webview-ui/src/components/CopyButton.tsx @@ -30,8 +30,8 @@ export function CopyButton({ content, className }: CopyButtonProps) { variant="ghost" size="icon-xs" className={cn( - "h-5 w-5 text-muted-foreground hover:text-foreground transition-all border-0! hover:bg-zinc-200 dark:hover:bg-zinc-800 cursor-pointer", - isCopied && "text-emerald-500 hover:text-emerald-600", + "h-5 w-5 text-muted-foreground hover:text-foreground transition-all border-0! hover:bg-toolbar-hover cursor-pointer", + isCopied && "text-success hover:text-success", className, )} onClick={() => { diff --git a/apps/vscode/webview-ui/src/components/DisplayBlocks.tsx b/apps/vscode/webview-ui/src/components/DisplayBlocks.tsx index 53b269ce..828b8038 100644 --- a/apps/vscode/webview-ui/src/components/DisplayBlocks.tsx +++ b/apps/vscode/webview-ui/src/components/DisplayBlocks.tsx @@ -4,10 +4,7 @@ import { oneDark, oneLight } from "react-syntax-highlighter/dist/esm/styles/pris import type { DisplayBlock, DiffBlock, TodoBlock, BriefBlock, ShellBlock } from "shared/legacy-sdk"; import { cn } from "@/lib/utils"; import * as Diff from "diff"; - -function useIsDark(): boolean { - return typeof document !== "undefined" && document.documentElement.classList.contains("dark"); -} +import { useIsDark } from "@/hooks/useIsDark"; interface DiffBlockProps { block: DiffBlock; @@ -25,18 +22,17 @@ function renderDiffLine(parts: DiffPart[], type: "added" | "removed"): React.Rea if (type === "removed") { if (part.added) return null; return ( - + {part.value} ); } - if (part.removed) return null; - return ( - - {part.value} - - ); - + if (part.removed) return null; + return ( + + {part.value} + + ); }); } @@ -97,24 +93,24 @@ export function DiffBlockView({ block, maxHeight = "max-h-40" }: DiffBlockProps)
{fileName}
{hasOld && ( -
+
{oldLines.map((lineParts, i) => (
- - - {renderDiffLine(lineParts, "removed") || " "} + - + {renderDiffLine(lineParts, "removed") || " "}
))}
)} {hasNew && ( -
+
{newLines.map((lineParts, i) => (
- + - {renderDiffLine(lineParts, "added") || " "} + + + {renderDiffLine(lineParts, "added") || " "}
))}
@@ -137,9 +133,9 @@ export function TodoBlockView({ block }: TodoBlockProps) { {item.title} @@ -163,7 +159,6 @@ interface ShellBlockProps { } export function ShellBlockView({ block, maxHeight = "max-h-40" }: ShellBlockProps) { - console.log("ShellBlockView render", { block }); const isDark = useIsDark(); const language = block.language || "bash"; @@ -201,6 +196,8 @@ export function DisplayBlockView({ block, maxHeight }: DisplayBlockViewProps) { return ; case "brief": return ; + case "shell": + return ; default: return null; } diff --git a/apps/vscode/webview-ui/src/components/FileChangesPanel.tsx b/apps/vscode/webview-ui/src/components/FileChangesPanel.tsx index 725395e9..19cedc87 100644 --- a/apps/vscode/webview-ui/src/components/FileChangesPanel.tsx +++ b/apps/vscode/webview-ui/src/components/FileChangesPanel.tsx @@ -9,9 +9,9 @@ import { FileChange } from "shared/types"; import { toast } from "./ui/sonner"; const STATUS_CONFIG = { - Added: { icon: IconFilePlus, color: "text-green-600 dark:text-green-400" }, - Deleted: { icon: IconFileX, color: "text-red-600 dark:text-red-400" }, - Modified: { icon: IconFileMinus, color: "text-yellow-600 dark:text-yellow-400" }, + Added: { icon: IconFilePlus, color: "text-success" }, + Deleted: { icon: IconFileX, color: "text-destructive" }, + Modified: { icon: IconFileMinus, color: "text-warning" }, } as const; function getTotalStats(changes: FileChange[]) { @@ -76,8 +76,8 @@ function FileItem({ file, onRevert, onKeep, onViewDiff, disabled, isStreaming }: )}
- +{file.additions} - -{file.deletions} + +{file.additions} + -{file.deletions}
); @@ -128,8 +128,8 @@ export function FileChangesPanel({ changes }: FileChangesPanelProps) { {changes.length} file{changes.length !== 1 ? "s" : ""}
- +{stats.additions} - -{stats.deletions} + +{stats.additions} + -{stats.deletions}
{!isStreaming && ( diff --git a/apps/vscode/webview-ui/src/components/Header.tsx b/apps/vscode/webview-ui/src/components/Header.tsx index 15019dce..084f9011 100644 --- a/apps/vscode/webview-ui/src/components/Header.tsx +++ b/apps/vscode/webview-ui/src/components/Header.tsx @@ -14,7 +14,7 @@ export function Header() { const [showSessionInfo, setShowSessionInfo] = useState(false); const [showConfirmNew, setShowConfirmNew] = useState(false); const { startNewConversation, sessionId, messages, isStreaming } = useChatStore(); - const { setSettingsDialogOpen } = useSettingsStore(); + const { openConfigHub } = useSettingsStore(); const handleNewSession = async () => { // If streaming, show confirmation dialog @@ -71,7 +71,7 @@ export function Header() { > -
diff --git a/apps/vscode/webview-ui/src/components/InlineError.tsx b/apps/vscode/webview-ui/src/components/InlineError.tsx index 5bce041f..302d93b4 100644 --- a/apps/vscode/webview-ui/src/components/InlineError.tsx +++ b/apps/vscode/webview-ui/src/components/InlineError.tsx @@ -15,14 +15,14 @@ export function InlineError({ error }: InlineErrorProps) { const showDetail = error.detail && error.detail !== error.message; return ( -
+
- - {error.message} + + {error.message}
- {showDetail &&
{error.detail}
} + {showDetail &&
{error.detail}
}
); } diff --git a/apps/vscode/webview-ui/src/components/LoginScreen.tsx b/apps/vscode/webview-ui/src/components/LoginScreen.tsx index f8c982fe..024f75f5 100644 --- a/apps/vscode/webview-ui/src/components/LoginScreen.tsx +++ b/apps/vscode/webview-ui/src/components/LoginScreen.tsx @@ -40,7 +40,7 @@ export function LoginScreen({ onLoginSuccess, onSkip }: LoginScreenProps) {
-
+
Waiting for authentication...
@@ -64,8 +64,8 @@ export function LoginScreen({ onLoginSuccess, onSkip }: LoginScreenProps) {
{error && ( -
-

{error}

+
+

{error}

)} diff --git a/apps/vscode/webview-ui/src/components/Markdown.tsx b/apps/vscode/webview-ui/src/components/Markdown.tsx index ab84e06d..fa1f1b18 100644 --- a/apps/vscode/webview-ui/src/components/Markdown.tsx +++ b/apps/vscode/webview-ui/src/components/Markdown.tsx @@ -13,6 +13,7 @@ import { CopyButton } from "@/components/CopyButton"; import { MediaPreviewModal, StreamImagePreview, ImageLoadFail } from "@/components/MediaPreviewModal"; import { getMediaTypeFromSrc } from "@/lib/media-utils"; import { bridge } from "@/services"; +import { useIsDark } from "@/hooks/useIsDark"; interface MarkdownProps { content: string; @@ -21,18 +22,6 @@ interface MarkdownProps { enableLocalImageRender?: boolean; } -function useIsDark(): boolean { - const [isDark, setIsDark] = useState(() => typeof document !== "undefined" && document.documentElement.classList.contains("dark")); - useEffect(() => { - if (typeof document === "undefined") return; - const el = document.documentElement; - const obs = new MutationObserver(() => setIsDark(el.classList.contains("dark"))); - obs.observe(el, { attributes: true, attributeFilter: ["class"] }); - return () => obs.disconnect(); - }, []); - return isDark; -} - function ColorSwatch({ color }: { color: string }) { return ; } @@ -46,7 +35,7 @@ export function FileLink({ path, display }: { path: string; display: string }) { [path], ); return ( - ); @@ -154,7 +143,7 @@ const CodeBlock = memo(function CodeBlock({ code, language, enableHighlight, sty {code} ) : ( -
+        
           {code}
         
)} @@ -222,7 +211,7 @@ export const Markdown = memo(function Markdown({ content, className, enableEnric ul: ({ children }) =>
    {children}
, ol: ({ children }) =>
    {children}
, a: ({ href, children }) => ( - + {children} ), diff --git a/apps/vscode/webview-ui/src/components/MediaPreviewModal.tsx b/apps/vscode/webview-ui/src/components/MediaPreviewModal.tsx index d056fed8..399bddd1 100644 --- a/apps/vscode/webview-ui/src/components/MediaPreviewModal.tsx +++ b/apps/vscode/webview-ui/src/components/MediaPreviewModal.tsx @@ -16,7 +16,7 @@ export function ImagePlaceholder() { export function ImageLoadFail({ path }: { path: string }) { return ( - + {path} ); diff --git a/apps/vscode/webview-ui/src/components/MediaThumbnail.tsx b/apps/vscode/webview-ui/src/components/MediaThumbnail.tsx index ffc7dc56..315ddfd1 100644 --- a/apps/vscode/webview-ui/src/components/MediaThumbnail.tsx +++ b/apps/vscode/webview-ui/src/components/MediaThumbnail.tsx @@ -21,7 +21,7 @@ function ThumbnailWrapper({ onClick, onRemove, sizeClass, children }: ThumbnailW e.stopPropagation(); onRemove(); }} - className="absolute -top-1.5 -right-1.5 size-5 rounded-full text-red-500 bg-white border border-red-500 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer" + className="absolute -top-1.5 -right-1.5 size-5 rounded-full text-destructive bg-background border border-destructive/40 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer" > diff --git a/apps/vscode/webview-ui/src/components/PermissionModeBadge.tsx b/apps/vscode/webview-ui/src/components/PermissionModeBadge.tsx deleted file mode 100644 index 52e6d069..00000000 --- a/apps/vscode/webview-ui/src/components/PermissionModeBadge.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; -import type { PermissionMode } from "shared/legacy-sdk"; - -const LABELS: Partial> = { - yolo: { - text: "YOLO", - hint: "Tool actions are auto-approved; the agent may still ask questions. Send /yolo off to stop.", - }, - auto: { - text: "AUTO", - hint: "Fully autonomous; the agent will not ask questions. Send /auto off to stop.", - }, -}; - -/** - * Shows the permission mode whenever it is not the default. - * - * `/yolo` and `/auto` toggle when sent without an argument, so a chat that - * never showed the mode let the same command mean "on" or "off" depending on - * state nobody could see — sending `/yolo` to be sure it was on turned it off. - * The terminal has always shown this; the red matches its danger row. - */ -export function PermissionModeBadge({ mode }: { mode: PermissionMode }) { - const label = LABELS[mode]; - if (label === undefined) return null; - - return ( - - - - {label.text} - - - {label.hint} - - ); -} diff --git a/apps/vscode/webview-ui/src/components/PlanCard.tsx b/apps/vscode/webview-ui/src/components/PlanCard.tsx index df0decf0..f6d61bfd 100644 --- a/apps/vscode/webview-ui/src/components/PlanCard.tsx +++ b/apps/vscode/webview-ui/src/components/PlanCard.tsx @@ -10,15 +10,15 @@ export function PlanCard({ children }: PlanCardProps) { const [collapsed, setCollapsed] = useState(false); return ( -
+
{!collapsed && (
diff --git a/apps/vscode/webview-ui/src/components/PlanModeButton.tsx b/apps/vscode/webview-ui/src/components/PlanModeButton.tsx deleted file mode 100644 index be2d0c3f..00000000 --- a/apps/vscode/webview-ui/src/components/PlanModeButton.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { IconClipboardList } from "@tabler/icons-react"; -import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; -import { cn } from "@/lib/utils"; - -interface PlanModeButtonProps { - active: boolean; - onToggle: () => void; -} - -export function PlanModeButton({ active, onToggle }: PlanModeButtonProps) { - const tooltipText = active ? "Plan mode active (click to exit)" : "Enter plan mode"; - - return ( - - - - - {tooltipText} - - ); -} diff --git a/apps/vscode/webview-ui/src/components/PythinkerMascot.tsx b/apps/vscode/webview-ui/src/components/PythinkerMascot.tsx index 587603a1..a9d28805 100644 --- a/apps/vscode/webview-ui/src/components/PythinkerMascot.tsx +++ b/apps/vscode/webview-ui/src/components/PythinkerMascot.tsx @@ -1,23 +1,9 @@ import { cn } from "@/lib/utils"; -import { useState, useEffect } from "react"; import { useExtensionImageUrl } from "./hooks/useExtensionImageUrl"; +import { useIsDark } from "@/hooks/useIsDark"; export function PythinkerMascot({ className }: { className?: string }) { - const [isDark, setIsDark] = useState(() => document.documentElement.classList.contains("dark")); - - useEffect(() => { - const checkTheme = () => { - setIsDark(document.documentElement.classList.contains("dark")); - }; - - const observer = new MutationObserver(checkTheme); - observer.observe(document.documentElement, { - attributes: true, - attributeFilter: ["class"], - }); - - return () => observer.disconnect(); - }, []); + const isDark = useIsDark(); const imageName = isDark ? "pythinker_banner_dark.svg" : "pythinker_banner_light.svg"; const logoUrl = useExtensionImageUrl(imageName); diff --git a/apps/vscode/webview-ui/src/components/QuestionDialog.tsx b/apps/vscode/webview-ui/src/components/QuestionDialog.tsx index c4919269..6f3a15c2 100644 --- a/apps/vscode/webview-ui/src/components/QuestionDialog.tsx +++ b/apps/vscode/webview-ui/src/components/QuestionDialog.tsx @@ -1,7 +1,9 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect, useRef } from "react"; import { useChatStore } from "@/stores"; import { cn } from "@/lib/utils"; +const focusComposer = () => document.querySelector("textarea")?.focus(); + export function QuestionDialog() { const { pendingQuestion, respondQuestion } = useChatStore(); const [customInput, setCustomInput] = useState(""); @@ -9,6 +11,10 @@ export function QuestionDialog() { const [selectedIndex, setSelectedIndex] = useState(1); const [questionIndex, setQuestionIndex] = useState(0); const [answers, setAnswers] = useState>({}); + const cardRef = useRef(null); + // The question stays pending until the RPC settles, so repeated key presses + // would submit duplicate answers without this guard. + const inFlightRef = useRef(false); const questions = pendingQuestion?.questions ?? []; const question = questions[questionIndex]; @@ -20,6 +26,7 @@ export function QuestionDialog() { setSelectedIndex(1); setQuestionIndex(0); setAnswers({}); + cardRef.current?.focus(); } }, [pendingQuestion?.id]); @@ -35,7 +42,14 @@ export function QuestionDialog() { setCustomInput(""); setSelectedIndex(1); } else { - await respondQuestion(nextAnswers); + if (inFlightRef.current) return; + inFlightRef.current = true; + try { + await respondQuestion(nextAnswers); + focusComposer(); + } finally { + inFlightRef.current = false; + } } }; @@ -51,8 +65,44 @@ export function QuestionDialog() { const options = question.options || []; const customIndex = options.length + 1; + const handleKeyDown = (e: React.KeyboardEvent) => { + if (showCustom) return; // the custom input owns the keyboard while open + const digit = Number(e.key); + if (digit >= 1 && digit <= customIndex) { + e.preventDefault(); + if (digit === customIndex) { + setShowCustom(true); + } else { + const opt = options[digit - 1]; + if (opt) void handleSelect(opt.label); + } + } else if (e.key === "ArrowDown") { + e.preventDefault(); + setSelectedIndex((i) => Math.min(i + 1, customIndex)); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + setSelectedIndex((i) => Math.max(i - 1, 1)); + } else if (e.key === "Enter") { + e.preventDefault(); + if (selectedIndex === customIndex) { + setShowCustom(true); + } else { + const opt = options[selectedIndex - 1]; + if (opt) void handleSelect(opt.label); + } + } else if (e.key === "Escape") { + e.preventDefault(); + focusComposer(); + } + }; + return ( -
+
{questions.length > 1 && (
@@ -72,13 +122,13 @@ export function QuestionDialog() { className={cn( "w-full text-left px-2 py-1 rounded-md text-xs transition-colors", "border border-border cursor-pointer", - selectedIndex === idx + 1 ? "bg-blue-500 text-white border-blue-500" : "bg-background hover:bg-muted/50", + selectedIndex === idx + 1 ? "bg-primary text-primary-foreground border-primary" : "bg-background hover:bg-muted/50", )} > - {idx + 1} + {idx + 1} {option.label} {option.description && ( - - {option.description} + - {option.description} )} ))} @@ -90,17 +140,20 @@ export function QuestionDialog() { onChange={(e) => setCustomInput(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") void handleCustomSubmit(); - if (e.key === "Escape") setShowCustom(false); + if (e.key === "Escape") { + setShowCustom(false); + cardRef.current?.focus(); + } }} placeholder="Enter your response..." - className="flex-1 px-2 py-1 rounded-md text-xs border border-border bg-background outline-none focus:border-blue-500" + className="flex-1 px-2 py-1 rounded-md text-xs border border-border bg-background outline-none focus:border-ring" /> @@ -112,10 +165,10 @@ export function QuestionDialog() { className={cn( "w-full text-left px-2 py-1 rounded-md text-xs transition-colors", "border border-border cursor-pointer", - selectedIndex === customIndex ? "bg-blue-500 text-white border-blue-500" : "bg-background hover:bg-muted/50", + selectedIndex === customIndex ? "bg-primary text-primary-foreground border-primary" : "bg-background hover:bg-muted/50", )} > - {customIndex} + {customIndex} Custom response... )} diff --git a/apps/vscode/webview-ui/src/components/QueuedMessagesPanel.tsx b/apps/vscode/webview-ui/src/components/QueuedMessagesPanel.tsx index dc6c982b..290f98e3 100644 --- a/apps/vscode/webview-ui/src/components/QueuedMessagesPanel.tsx +++ b/apps/vscode/webview-ui/src/components/QueuedMessagesPanel.tsx @@ -31,7 +31,7 @@ function QueueItem({ id, content, isStreaming, onEdit }: { id: string; content:
-
- {loading ? ( -
Loading...
- ) : filteredSessions.length === 0 ? ( -
{searchQuery ? "No conversations found" : "No conversations yet"}
- ) : ( - filteredSessions.map((session) => ( - { - void handleSelect(session); - }} - onDelete={() => setDeleteTarget(session)} - dirLabel={getWorkDirLabel(session.workDir)} - /> - )) - )} -
+ {loading ? ( +
Loading...
+ ) : isEmpty ? ( +
{searchQuery ? "No conversations found" : "No conversations yet"}
+ ) : ( +
+ {groupedSessions.map((group) => ( +
+
{group.label}
+
+ {group.items.map((session) => ( + { + void handleSelect(session); + }} + onDelete={() => setDeleteTarget(session)} + dirLabel={getWorkDirLabel(session.workDir)} + searchQuery={searchQuery} + /> + ))} +
+
+ ))} +
+ )}
diff --git a/apps/vscode/webview-ui/src/components/SilverSpinner.tsx b/apps/vscode/webview-ui/src/components/SilverSpinner.tsx index 41b59dd0..1fa4d471 100644 --- a/apps/vscode/webview-ui/src/components/SilverSpinner.tsx +++ b/apps/vscode/webview-ui/src/components/SilverSpinner.tsx @@ -3,7 +3,7 @@ import { cn } from "@/lib/utils"; export function SilverSpinner({ className }: { className?: string }) { return ( - + ); } diff --git a/apps/vscode/webview-ui/src/components/ThinkingBlock.tsx b/apps/vscode/webview-ui/src/components/ThinkingBlock.tsx index 8c093807..321dd7a2 100644 --- a/apps/vscode/webview-ui/src/components/ThinkingBlock.tsx +++ b/apps/vscode/webview-ui/src/components/ThinkingBlock.tsx @@ -21,14 +21,14 @@ export function ThinkingBlock({ content, finished, compact }: ThinkingBlockProps if (!showThinkingContent) { // Hidden mode: static label, no interaction return ( -
+
- - Thinking - {isStreaming && } + + Thinking + {isStreaming && }
@@ -41,23 +41,23 @@ export function ThinkingBlock({ content, finished, compact }: ThinkingBlockProps } return ( -
+
{expanded && content && ( )} diff --git a/apps/vscode/webview-ui/src/components/ThinkingButton.tsx b/apps/vscode/webview-ui/src/components/ThinkingButton.tsx deleted file mode 100644 index 236d53fc..00000000 --- a/apps/vscode/webview-ui/src/components/ThinkingButton.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import { IconBulb, IconCheck } from "@tabler/icons-react"; - -import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; -import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; -import { cn } from "@/lib/utils"; -import type { ThinkingMode } from "shared/legacy-sdk"; - -interface ThinkingButtonProps { - mode: ThinkingMode; - effort: string; - efforts?: string[]; - alwaysOn?: boolean; - disabled?: boolean; - /** When set, rendered as a muted wrapping note below the effort options - * (e.g. the mid-conversation switch cache-cost notice). */ - cacheNote?: string; - onToggle: () => void; - onSelectEffort: (effort: string) => void; -} - -function label(effort: string): string { - return effort.charAt(0).toUpperCase() + effort.slice(1); -} - -export function ThinkingButton({ mode, effort, efforts = [], alwaysOn = false, disabled, cacheNote, onToggle, onSelectEffort }: ThinkingButtonProps) { - if (mode === "none") return null; - - const active = effort !== "off" || alwaysOn; - const button = ( - - ); - - if (mode === "effort") { - const options = alwaysOn ? efforts : ["off", ...efforts]; - return ( - - - - {button} - - Thinking effort: {label(effort)} - - - {options.map((option) => ( - onSelectEffort(option)} className="text-xs gap-2"> - - {label(option)} - - ))} - {cacheNote !== undefined && ( -
- {cacheNote} -
- )} -
-
- ); - } - - const tooltip = mode === "always" ? "Thinking is always enabled for this model" : active ? "Thinking enabled" : "Enable thinking"; - return ( - - {button} - {tooltip} - - ); -} diff --git a/apps/vscode/webview-ui/src/components/ToolRenderers.tsx b/apps/vscode/webview-ui/src/components/ToolRenderers.tsx index 9026683a..207ba584 100644 --- a/apps/vscode/webview-ui/src/components/ToolRenderers.tsx +++ b/apps/vscode/webview-ui/src/components/ToolRenderers.tsx @@ -50,7 +50,7 @@ function getRichDisplayBlocks(display?: DisplayBlock[]): DisplayBlock[] { if (!display) { return []; } - return display.filter((b) => b.type === "diff"); + return display.filter((b) => b.type === "diff" || b.type === "shell"); } function CodeBlock({ content, maxLines = 10 }: { content: string; maxLines?: number }) { @@ -61,14 +61,14 @@ function CodeBlock({ content, maxLines = 10 }: { content: string; maxLines?: num return (
-
+      
         {displayContent}
-        {shouldCollapse && !expanded && {"\n"}...}
+        {shouldCollapse && !expanded && {"\n"}...}
       
{shouldCollapse && ( @@ -81,12 +81,12 @@ function StatusIndicator({ status }: { status: "pending" | "success" | "error" } if (status === "pending") { return ( - - + + ); } - return ; + return ; } function ToolIcon({ name }: { name: string }) { @@ -124,14 +124,14 @@ function TodoStatusIcon({ status }: { status: string }) { if (status === "done") { return (
- +
); } if (status === "in_progress") { - return ; + return ; } - return ; + return ; } function SetTodoListTool({ result }: ToolRendererProps) { @@ -217,7 +217,7 @@ function WriteFileTool({ call, result }: ToolRendererProps) { {hasRichDisplay ? ( ) : ( - {!result.is_error ? "✓ Written" : formatOutput(result.output)} + {!result.is_error ? "✓ Written" : formatOutput(result.output)} )} )} @@ -241,7 +241,7 @@ function StrReplaceFileTool({ call, result }: ToolRendererProps) { {hasRichDisplay ? ( ) : ( - + {!result.is_error ? "✓ Replaced successfully" : formatOutput(result.output)} )} @@ -292,7 +292,7 @@ function GenericTool({ call, result }: ToolRendererProps) { ) : output ? ( ) : ( - {!result.is_error ? "✓ Done" : "✗ Failed"} + {!result.is_error ? "✓ Done" : "✗ Failed"} )} )} @@ -340,7 +340,7 @@ function TaskTool({ call, result, subagentSteps }: ToolRendererProps) {
- {subagentName} + {subagentName} {description}
{prompt &&
{prompt}
} @@ -427,7 +427,7 @@ export function ToolCallCard({ call, result, subagentSteps, subagentStatus, work {call.name} {getToolLabel(call)} - {subagentSteps && subagentSteps.length > 0 && {subagentSteps.length} steps} + {subagentSteps && subagentSteps.length > 0 && {subagentSteps.length} steps} {expanded &&
{renderContent()}
} diff --git a/apps/vscode/webview-ui/src/components/WelcomeScreen.tsx b/apps/vscode/webview-ui/src/components/WelcomeScreen.tsx index 4d4d2a29..dec3c5fc 100644 --- a/apps/vscode/webview-ui/src/components/WelcomeScreen.tsx +++ b/apps/vscode/webview-ui/src/components/WelcomeScreen.tsx @@ -1,8 +1,26 @@ import { PythinkerMascot } from "./PythinkerMascot"; import { useWelcomeHint } from "@/hooks/useWelcomeHint"; +import { Button } from "@/components/ui/button"; +import { bridge, Events } from "@/services"; +import { useChatStore } from "@/stores"; +import { cleanSystemTags } from "shared/utils"; +import { toast } from "./ui/sonner"; +import type { SessionInfo } from "shared/legacy-sdk"; +import { formatRelativeDate } from "@/lib/format"; export function WelcomeScreen() { - const hint = useWelcomeHint(); + const { hint, recentSessions } = useWelcomeHint(); + const loadSession = useChatStore((s) => s.loadSession); + const { slashCommand } = hint; + + const openSession = async (session: SessionInfo) => { + try { + const events = await bridge.loadSessionHistory(session.id); + await loadSession(session.id, events); + } catch (error) { + toast.error(`Unable to open the conversation: ${error instanceof Error ? error.message : String(error)}`); + } + }; return (
@@ -10,9 +28,40 @@ export function WelcomeScreen() { {hint.component ? ( hint.component ) : ( -
+

{hint.title}

{hint.description}

+ {slashCommand && ( + + )} +
+ )} + {recentSessions.length > 0 && ( +
+

+ Recent sessions +

+
+ {recentSessions.map((session) => ( + + ))} +
)}
diff --git a/apps/vscode/webview-ui/src/components/WorkDirModal.tsx b/apps/vscode/webview-ui/src/components/WorkDirModal.tsx index 3cba9263..a15c60cb 100644 --- a/apps/vscode/webview-ui/src/components/WorkDirModal.tsx +++ b/apps/vscode/webview-ui/src/components/WorkDirModal.tsx @@ -84,7 +84,7 @@ export function WorkDirModal() { )} {displayPath(dir)} - {isSelected(dir) && } + {isSelected(dir) && } {dir === workspaceRoot && (root)} ))} diff --git a/apps/vscode/webview-ui/src/components/WorkflowCard.tsx b/apps/vscode/webview-ui/src/components/WorkflowCard.tsx index ace218c1..47ec3669 100644 --- a/apps/vscode/webview-ui/src/components/WorkflowCard.tsx +++ b/apps/vscode/webview-ui/src/components/WorkflowCard.tsx @@ -78,7 +78,7 @@ function LaneRow({ lane, workflowEnded, renderStepItem }: { lane: WorkflowLane; {/* What the lane is doing now takes the slack, so the counts stay in a column instead of drifting with the width of each label. */} {abandoned ? "" : (runningToolLabel ?? "")} - + {queued ? "queued" : `${abandoned ? "no result · " : lane.status === "done" ? "done · " : ""}${lane.stepCount} step${lane.stepCount === 1 ? "" : "s"}`} {duration && ` · ${duration}`} @@ -140,7 +140,7 @@ export function WorkflowCard({ call, result, subagentSteps, subagentStatus, work
{workflowWarning && ( -
+
{workflowWarning.message}
)} @@ -150,7 +150,7 @@ export function WorkflowCard({ call, result, subagentSteps, subagentStatus, work ))}
{abandonedCount > 0 && ( -
+
{abandonedCount} agent{abandonedCount === 1 ? "" : "s"} stopped without reporting a result.
)} diff --git a/apps/vscode/webview-ui/src/components/confighub/ConfigFileSection.tsx b/apps/vscode/webview-ui/src/components/confighub/ConfigFileSection.tsx new file mode 100644 index 00000000..5cfb7497 --- /dev/null +++ b/apps/vscode/webview-ui/src/components/confighub/ConfigFileSection.tsx @@ -0,0 +1,80 @@ +import { useCallback, useEffect, useState } from "react"; +import { IconExternalLink, IconLoader2, IconRefresh } from "@tabler/icons-react"; +import { Button } from "@/components/ui/button"; +import { bridge } from "@/services"; +import type { ConfigInfo } from "shared/types"; + +export function ConfigFileSection() { + const [info, setInfo] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(); + + const load = useCallback(() => { + setLoading(true); + setError(undefined); + bridge + .getConfigInfo() + .then(setInfo) + .catch((error: unknown) => setError(error instanceof Error ? error.message : String(error))) + .finally(() => setLoading(false)); + }, []); + + useEffect(() => { + load(); + }, [load]); + + if (loading && info === null) { + return ( +
+ + Reading config file… +
+ ); + } + + return ( +
+
+

Config File

+ +
+ + {error && ( +
+ {error} +
+ )} + + {info === null ? ( + // A load error with no result means the state is unknown — the error + // banner above is the whole story, so do not claim the file is missing. + error === undefined &&

No config file was found.

+ ) : info.path === null || !info.exists ? ( +

No config file was found.

+ ) : ( + <> +
+ + {info.path} + + +
+
+            {info.content ?? ""}
+          
+ + )} +
+ ); +} diff --git a/apps/vscode/webview-ui/src/components/confighub/ConfigHub.tsx b/apps/vscode/webview-ui/src/components/confighub/ConfigHub.tsx new file mode 100644 index 00000000..9bcfc6c7 --- /dev/null +++ b/apps/vscode/webview-ui/src/components/confighub/ConfigHub.tsx @@ -0,0 +1,104 @@ +import { useEffect } from "react"; +import { Button } from "@/components/ui/button"; +import { useSettingsStore } from "@/stores"; +import type { ConfigHubSection } from "@/stores/settings.store"; +import { bridge } from "@/services"; +import { cn } from "@/lib/utils"; +import { SECTIONS } from "./sections"; +import { OverviewSection } from "./OverviewSection"; +import { ModelsSection } from "./ModelsSection"; +import { ProvidersSection } from "./ProvidersSection"; +import { MCPServersSection } from "./MCPServersSection"; +import { ConfigFileSection } from "./ConfigFileSection"; +import { SettingsSection } from "./SettingsSection"; + +export function useSectionCounts(): Partial> { + const { models, mcpServers } = useSettingsStore(); + return { + models: models.length, + providers: new Set(models.map((m) => m.provider)).size, + mcp: mcpServers.length, + }; +} + +export function ConfigHub() { + const { configHub, openConfigHub, closeConfigHub, setMCPServers } = useSettingsStore(); + const counts = useSectionCounts(); + const section = configHub.section; + const active = SECTIONS.find((s) => s.id === section) ?? SECTIONS[0]; + + // Keep the rail/overview MCP count fresh; the MCP section surfaces fetch + // errors itself, so a failed count refresh stays silent here. + useEffect(() => { + void bridge.getMCPServers().then(setMCPServers).catch(() => undefined); + }, [setMCPServers]); + + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if (e.key !== "Escape") return; + // A nested dialog (e.g. delete confirm) owns Escape while it is open. + if (e.target instanceof Element && e.target.closest('[role="dialog"], [role="alertdialog"]')) return; + closeConfigHub(); + }; + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, [closeConfigHub]); + + return ( +
+
+
+ Config Hub + / + {active.label} +
+ +
+ +
+ + +
+ {section === "overview" && } + {section === "models" && } + {section === "providers" && } + {section === "mcp" && } + {section === "config" && } + {section === "settings" && } +
+
+
+ ); +} diff --git a/apps/vscode/webview-ui/src/components/MCPServersModal.tsx b/apps/vscode/webview-ui/src/components/confighub/MCPServersSection.tsx similarity index 83% rename from apps/vscode/webview-ui/src/components/MCPServersModal.tsx rename to apps/vscode/webview-ui/src/components/confighub/MCPServersSection.tsx index 326534f6..fd3ca56c 100644 --- a/apps/vscode/webview-ui/src/components/MCPServersModal.tsx +++ b/apps/vscode/webview-ui/src/components/confighub/MCPServersSection.tsx @@ -207,7 +207,7 @@ function ServerForm({ onClick={() => set("transport", t)} className={cn( "flex-1 h-7 text-xs rounded border flex items-center justify-center gap-1", - data.transport === t ? "border-blue-500 bg-blue-500/10 text-blue-500" : "border-border", + data.transport === t ? "border-brand bg-brand/10 text-brand" : "border-border", )} > {t === "stdio" ? : } @@ -348,13 +348,13 @@ function ServerItem({ server, onDelete }: { server: MCPServerConfig; onDelete: ( return (
setExpanded(!expanded)}> -
+
{isHttp ? : }
{server.name} - {server.auth === "oauth" && OAuth} + {server.auth === "oauth" && OAuth}

{isHttp ? server.url : ( @@ -407,7 +407,7 @@ function ServerItem({ server, onDelete }: { server: MCPServerConfig; onDelete: ( function RecommendedItem({ server, onInstall, isInstalling }: { server: RecommendedMCPServer; onInstall: () => void; isInstalling: boolean }) { return (

-
+
@@ -435,8 +435,8 @@ function RecommendedItem({ server, onInstall, isInstalling }: { server: Recommen ); } -export function MCPServersModal() { - const { mcpServers, mcpModalOpen, setMCPServers, setMCPModalOpen } = useSettingsStore(); +export function MCPServersSection() { + const { mcpServers, setMCPServers } = useSettingsStore(); const [showAdd, setShowAdd] = useState(false); const [addForm, setAddForm] = useState(() => emptyForm()); const [installingRecommended, setInstallingRecommended] = useState(null); @@ -445,12 +445,10 @@ export function MCPServersModal() { const [actionError, setActionError] = useState(null); useEffect(() => { - if (mcpModalOpen) { - void bridge.getMCPServers().then(setMCPServers).catch((error: unknown) => { - setActionError(error instanceof Error ? error.message : String(error)); - }); - } - }, [mcpModalOpen, setMCPServers]); + void bridge.getMCPServers().then(setMCPServers).catch((error: unknown) => { + setActionError(error instanceof Error ? error.message : String(error)); + }); + }, [setMCPServers]); useEffect(() => { if (!showAdd) setAddForm(emptyForm()); @@ -496,68 +494,55 @@ export function MCPServersModal() { setInstallingRecommended(null); }; - if (!mcpModalOpen) return null; - return ( <> -
-
-
- -

MCP Servers

-
-
- - -
+
+
+

MCP Servers

+
-
-
- {actionError && ( -
- {actionError} -
- )} - {showAdd && ( -
-
- - Add MCP Server -
- { void handleAdd(); }} onCancel={() => setShowAdd(false)} submitLabel="Add Server" /> -
- )} - {mcpServers.length > 0 && ( -
- {mcpServers.map((server) => ( - setDeleteTarget(server.name)} /> - ))} -
- )} + {actionError && ( +
+ {actionError} +
+ )} + {showAdd && ( +
+
+ + Add MCP Server +
+ { void handleAdd(); }} onCancel={() => setShowAdd(false)} submitLabel="Add Server" /> +
+ )} - {mcpServers.length === 0 && !showAdd && ( -
- -

No MCP servers configured

-
- )} + {mcpServers.length > 0 && ( +
+ {mcpServers.map((server) => ( + setDeleteTarget(server.name)} /> + ))} +
+ )} -
-

Recommended

- {RECOMMENDED_MCP_SERVERS.filter((s) => !installedNames.has(s.id)).map((server) => ( - { void handleInstallRecommended(server); }} isInstalling={installingRecommended === server.id} /> - ))} - {RECOMMENDED_MCP_SERVERS.every((s) => installedNames.has(s.id)) && ( -

All recommended servers installed

- )} -
+ {mcpServers.length === 0 && !showAdd && ( +
+ +

No MCP servers configured

+ )} + +
+

Recommended

+ {RECOMMENDED_MCP_SERVERS.filter((s) => !installedNames.has(s.id)).map((server) => ( + { void handleInstallRecommended(server); }} isInstalling={installingRecommended === server.id} /> + ))} + {RECOMMENDED_MCP_SERVERS.every((s) => installedNames.has(s.id)) && ( +

All recommended servers installed

+ )}
diff --git a/apps/vscode/webview-ui/src/components/confighub/ModelsSection.tsx b/apps/vscode/webview-ui/src/components/confighub/ModelsSection.tsx new file mode 100644 index 00000000..dcf83d87 --- /dev/null +++ b/apps/vscode/webview-ui/src/components/confighub/ModelsSection.tsx @@ -0,0 +1,72 @@ +import { IconCheck } from "@tabler/icons-react"; +import { Badge } from "@/components/ui/badge"; +import { groupModelsByProvider, useSettingsStore } from "@/stores"; +import { cn } from "@/lib/utils"; + +const CAPABILITY_LABELS: Record = { + image_in: "vision", + video_in: "video", + thinking: "thinking", + always_thinking: "always thinking", + tools: "tools", + tool_use: "tools", +}; + +function formatContextWindow(tokens: number): string { + return `${Math.round(tokens / 1000)}k`; +} + +export function ModelsSection() { + const { models, currentModel } = useSettingsStore(); + const groups = groupModelsByProvider(models); + + if (groups.length === 0) { + return

No models available. Add a provider first.

; + } + + return ( +
+ {groups.map((group) => ( +
+

+ {group.label} +

+
+ {group.models.map((model) => { + const isCurrent = model.id === currentModel; + return ( +
+ +
+
+ {model.name} + {model.contextWindow !== undefined && ( + + {formatContextWindow(model.contextWindow)} + + )} +
+

{model.id}

+
+
+ {model.capabilities.map((capability) => ( + + {CAPABILITY_LABELS[capability] ?? capability.replaceAll("_", " ")} + + ))} +
+
+ ); + })} +
+
+ ))} +
+ ); +} diff --git a/apps/vscode/webview-ui/src/components/confighub/OverviewSection.tsx b/apps/vscode/webview-ui/src/components/confighub/OverviewSection.tsx new file mode 100644 index 00000000..ae07c189 --- /dev/null +++ b/apps/vscode/webview-ui/src/components/confighub/OverviewSection.tsx @@ -0,0 +1,43 @@ +import { Button } from "@/components/ui/button"; +import type { ConfigHubSection } from "@/stores/settings.store"; +import { SECTIONS } from "./sections"; + +const DESCRIPTIONS: Record, string> = { + models: "Every model available to the agent, grouped by provider.", + providers: "API providers in config.toml — add, inspect, or remove them.", + mcp: "MCP servers the agent can call — manage, test, and authenticate.", + config: "The raw config.toml the extension and the CLI share.", + settings: "Extension behavior: approvals, autosave, shortcuts, thinking display.", +}; + +export function OverviewSection({ + onNavigate, + counts, +}: { + onNavigate: (section: ConfigHubSection) => void; + counts: Partial>; +}) { + return ( +
+ {SECTIONS.filter((s) => s.id !== "overview").map((s) => ( +
+
+ + {s.label} + {counts[s.id] !== undefined && ( + + {counts[s.id]} + + )} +
+

+ {DESCRIPTIONS[s.id as Exclude]} +

+ +
+ ))} +
+ ); +} diff --git a/apps/vscode/webview-ui/src/components/ProvidersModal.tsx b/apps/vscode/webview-ui/src/components/confighub/ProvidersSection.tsx similarity index 51% rename from apps/vscode/webview-ui/src/components/ProvidersModal.tsx rename to apps/vscode/webview-ui/src/components/confighub/ProvidersSection.tsx index 8c487cd0..118c0b08 100644 --- a/apps/vscode/webview-ui/src/components/ProvidersModal.tsx +++ b/apps/vscode/webview-ui/src/components/confighub/ProvidersSection.tsx @@ -1,14 +1,13 @@ import { useEffect, useMemo, useState } from "react"; import { + IconArrowLeft, IconCheck, IconChevronDown, IconKey, IconLoader2, - IconPlug, IconPlus, IconSearch, IconTrash, - IconX, } from "@tabler/icons-react"; import { Button } from "@/components/ui/button"; @@ -25,7 +24,6 @@ import { AlertDialogTitle, } from "@/components/ui/alert-dialog"; import { bridge } from "@/services"; -import { useSettingsStore } from "@/stores"; import { cn } from "@/lib/utils"; import type { CatalogProviderSummary, @@ -40,8 +38,7 @@ const KEY_SOURCE_LABEL: Record = { none: "No key configured", }; -export function ProvidersModal() { - const { providersModalOpen, setProvidersModalOpen } = useSettingsStore(); +export function ProvidersSection() { const [view, setView] = useState({ providers: [], defaultModel: null }); const [loading, setLoading] = useState(false); const [error, setError] = useState(); @@ -49,7 +46,6 @@ export function ProvidersModal() { const [deleteTarget, setDeleteTarget] = useState(); useEffect(() => { - if (!providersModalOpen) return; setLoading(true); setError(undefined); bridge @@ -57,9 +53,7 @@ export function ProvidersModal() { .then(setView) .catch((error: unknown) => setError(messageOf(error))) .finally(() => setLoading(false)); - }, [providersModalOpen]); - - if (!providersModalOpen) return null; + }, []); const remove = async (providerId: string) => { setDeleteTarget(undefined); @@ -73,71 +67,59 @@ export function ProvidersModal() { return ( <> -
-
-
- + {addOpen ? ( + setAddOpen(false)} + onAdded={(next) => { + setView(next); + setAddOpen(false); + }} + /> + ) : ( +
+

Model Providers

-
-
-
-
-
-
- {error && ( -
- {error} -
- )} + {error && ( +
+ {error} +
+ )} -

- Providers live in ~/.pythinker-code/config.toml, the same file the - CLI reads. Anything added here works in the terminal too. -

+

+ Providers live in ~/.pythinker-code/config.toml, the same file the + CLI reads. Anything added here works in the terminal too. +

- {loading && view.providers.length === 0 ? ( -
- - Reading config.toml… -
- ) : view.providers.length === 0 ? ( -
-

No providers configured yet.

- -
- ) : ( - view.providers.map((provider) => ( - setDeleteTarget(provider.id)} - /> - )) - )} -
+ {loading && view.providers.length === 0 ? ( +
+ + Reading config.toml… +
+ ) : view.providers.length === 0 ? ( +
+

No providers configured yet.

+ +
+ ) : ( + view.providers.map((provider) => ( + setDeleteTarget(provider.id)} + /> + )) + )}
-
- - {addOpen && ( - setAddOpen(false)} - onAdded={(next) => { - setView(next); - setAddOpen(false); - }} - /> )} !open && setDeleteTarget(undefined)}> @@ -178,23 +160,26 @@ function ProviderRow({ className="flex items-center gap-2 flex-1 min-w-0 text-left" aria-expanded={expanded} > - - {provider.id} - + + {provider.id} + {provider.host ?? provider.type} - + {provider.models.length} model{provider.models.length === 1 ? "" : "s"} - - {provider.keySource === "env" ? provider.apiKeyEnvVar : KEY_SOURCE_LABEL[provider.keySource]} + + + {provider.keySource === "env" ? provider.apiKeyEnvVar : KEY_SOURCE_LABEL[provider.keySource]} + +

{selected === undefined ? "Choose a provider" : `Connect ${selected.name}`}

-
-
- {error && ( -
- {error} -
- )} +
+ {error && ( +
+ {error} +
+ )} - {selected === undefined ? ( - <> -
- - setQuery(event.target.value)} - placeholder="Search providers…" - className="h-7 text-xs pl-7" - /> + {selected === undefined ? ( + <> +
+ + setQuery(event.target.value)} + placeholder="Search providers…" + className="h-7 text-xs pl-7" + /> +
+ {loading ? ( +
+ + Loading the models.dev catalog…
- {loading ? ( -
- - Loading the models.dev catalog… -
- ) : ( -
- {matches.map((entry) => ( - - ))} - {matches.length === 0 && ( -

No provider matches “{query}”.

- )} -
- )} - - ) : ( - <> -
- - {useEnvVar ? ( -

- Read at connection time from{" "} - {selected.apiKeyEnvVar}. The key is never written to disk. -

- ) : ( - setApiKey(event.target.value)} - placeholder="sk-…" - className="h-7 text-xs font-mono" - /> - )} - {selected.apiKeyEnvVar !== undefined && ( + ) : ( +
+ {matches.map((entry) => ( - )} - {!useEnvVar && ( -

- Stored in config.toml in plain text, as the CLI does. -

+ ))} + {matches.length === 0 && ( +

No provider matches “{query}”.

)}
- -
- -
- {selected.models.map((model) => ( - - ))} -
+ )} + + ) : ( + <> +
+ + {useEnvVar ? ( +

+ Read at connection time from{" "} + {selected.apiKeyEnvVar}. The key is never written to disk. +

+ ) : ( + setApiKey(event.target.value)} + placeholder="sk-…" + className="h-7 text-xs font-mono" + /> + )} + {selected.apiKeyEnvVar !== undefined && ( + + )} + {!useEnvVar && (

- All {selected.models.length} models are imported either way. Leave this unset to keep your current - default. + Stored in config.toml in plain text, as the CLI does.

+ )} +
+ +
+ +
+ {selected.models.map((model) => ( + + ))}
- - )} -
+

+ All {selected.models.length} models are imported either way. Leave this unset to keep your current + default. +

+
+ + )}
{selected !== undefined && ( -
+
-
+