diff --git a/.changeset/web-capability-menu.md b/.changeset/web-capability-menu.md new file mode 100644 index 00000000..1e9252f0 --- /dev/null +++ b/.changeset/web-capability-menu.md @@ -0,0 +1,5 @@ +--- +"@pymodel/pythinker-code": minor +--- + +Add a capability menu to the composer. It picks which tools and MCP servers the current session may use, lists the session's skills, and turns plugins on or off. Each group states how far its change reaches, because the three differ: tool and MCP changes apply to this session at once, skills are read-only here, and plugin changes are global to the daemon. Selected tools and servers appear as chips beside the composer controls. diff --git a/.changeset/web-composer-shell.md b/.changeset/web-composer-shell.md new file mode 100644 index 00000000..cc0a9107 --- /dev/null +++ b/.changeset/web-composer-shell.md @@ -0,0 +1,5 @@ +--- +"@pymodel/pythinker-code": patch +--- + +Widen the chat reading column to 928px and restyle the composer card: a 24px radius, a translucent blurred surface, a border that strengthens on hover and focus, and an input that grows to 384px before it scrolls. The toolbar controls are 30px circles with a divider after the attachment button. diff --git a/apps/pythinker-web/src/api/daemon/client.ts b/apps/pythinker-web/src/api/daemon/client.ts index 07a54b8a..b68a8118 100644 --- a/apps/pythinker-web/src/api/daemon/client.ts +++ b/apps/pythinker-web/src/api/daemon/client.ts @@ -14,6 +14,7 @@ import type { AppConnector, AppPlugin, AppSkill, + AppTool, AppSubagent, AppSessionCursor, AppSessionRuntimeStatus, @@ -130,6 +131,14 @@ interface WireSkillDescriptor { disable_model_invocation?: boolean; } +interface WireToolDescriptor { + name: string; + description: string; + input_schema: unknown; + source: 'builtin' | 'skill' | 'mcp'; + mcp_server_id?: string; +} + interface WireMcpServer { id: string; name: string; @@ -381,6 +390,8 @@ export class DaemonPythinkerWebApi implements PythinkerWebApi { goalObjective?: string; goalControl?: 'pause' | 'resume' | 'cancel'; thinking?: string; + tools?: string[]; + mcpServers?: string[]; }, ): Promise { const body: Record = {}; @@ -394,6 +405,8 @@ export class DaemonPythinkerWebApi implements PythinkerWebApi { if (input.goalObjective !== undefined) agentConfig['goal_objective'] = input.goalObjective; if (input.goalControl !== undefined) agentConfig['goal_control'] = input.goalControl; if (input.thinking !== undefined) agentConfig['thinking'] = input.thinking; + if (input.tools !== undefined) agentConfig['tools'] = input.tools; + if (input.mcpServers !== undefined) agentConfig['mcp_servers'] = input.mcpServers; if (Object.keys(agentConfig).length > 0) body['agent_config'] = agentConfig; const data = await this.http.post( `/sessions/${encodeURIComponent(sessionId)}/profile`, @@ -729,6 +742,23 @@ export class DaemonPythinkerWebApi implements PythinkerWebApi { } // ------------------------------------------------------------------------- + // Tools — session-visible tool descriptors + // GET /tools?session_id={id} → { tools: WireToolDescriptor[] } + // ------------------------------------------------------------------------- + + async listTools(sessionId: string): Promise { + const data = await this.http.get<{ tools: WireToolDescriptor[] }>('/tools', { + session_id: sessionId, + }); + return (data.tools ?? []).map((tool) => ({ + name: tool.name, + description: tool.description, + inputSchema: tool.input_schema, + source: tool.source, + mcpServerId: tool.mcp_server_id, + })); + } + // Skills — session-scoped slash-invocable skills // GET /sessions/{id}/skills → { skills: WireSkillDescriptor[] } // POST /sessions/{id}/skills/{name}:activate body { args? } → { activated, skill_name } diff --git a/apps/pythinker-web/src/api/daemon/mappers.ts b/apps/pythinker-web/src/api/daemon/mappers.ts index 3e81ce9d..da6ecde8 100644 --- a/apps/pythinker-web/src/api/daemon/mappers.ts +++ b/apps/pythinker-web/src/api/daemon/mappers.ts @@ -101,6 +101,8 @@ export function toAppSession(wire: WireSession): AppSession { currentPromptId: wire.current_prompt_id, cwd: wire.metadata.cwd, model: wire.agent_config.model, + tools: wire.agent_config.tools, + mcpServers: wire.agent_config.mcp_servers, usage: toAppSessionUsage(wire.usage), messageCount: wire.message_count, lastSeq: wire.last_seq, diff --git a/apps/pythinker-web/src/api/types.ts b/apps/pythinker-web/src/api/types.ts index 5f995fa0..2775f0d0 100644 --- a/apps/pythinker-web/src/api/types.ts +++ b/apps/pythinker-web/src/api/types.ts @@ -69,6 +69,10 @@ export interface AppSession { currentPromptId?: string; cwd: string; model: string; + /** Exact tool names configured for this session, when explicitly set. */ + tools?: string[]; + /** MCP server ids configured for this session, when explicitly set. */ + mcpServers?: string[]; usage: AppSessionUsage; messageCount: number; lastSeq: number; @@ -651,6 +655,15 @@ export interface AppConnector { lastError?: string; } +/** One tool available to the current session. */ +export interface AppTool { + name: string; + description: string; + inputSchema: unknown; + source: 'builtin' | 'skill' | 'mcp'; + mcpServerId?: string; +} + // --------------------------------------------------------------------------- // PythinkerWebApi — the app-facing interface // --------------------------------------------------------------------------- @@ -662,7 +675,7 @@ export interface PythinkerWebApi { createSession(input: { title?: string; cwd?: string; model?: string; workspaceId?: string }): Promise; /** Fetch one session by id (deep links beyond the first listSessions page). */ getSession(sessionId: string): Promise; - updateSession(sessionId: string, input: { title?: string; cwd?: string; model?: string; permissionMode?: string; planMode?: boolean; dynamicWorkflowMode?: boolean; goalObjective?: string; goalControl?: 'pause' | 'resume' | 'cancel'; thinking?: string }): Promise; + updateSession(sessionId: string, input: { title?: string; cwd?: string; model?: string; permissionMode?: string; planMode?: boolean; dynamicWorkflowMode?: boolean; goalObjective?: string; goalControl?: 'pause' | 'resume' | 'cancel'; thinking?: string; tools?: string[]; mcpServers?: string[] }): Promise; getSessionStatus(sessionId: string): Promise; archiveSession(sessionId: string): Promise<{ archived: true }>; listMessages(sessionId: string, input?: PageRequest & { role?: AppMessageRole }): Promise>; @@ -687,6 +700,7 @@ export interface PythinkerWebApi { respondQuestion(sessionId: string, questionId: string, response: QuestionResponse): Promise<{ resolved: true; resolvedAt: string }>; dismissQuestion(sessionId: string, questionId: string): Promise<{ dismissed: true; dismissedAt: string }>; listSkills(sessionId: string): Promise; + listTools(sessionId: string): Promise; activateSkill(sessionId: string, skillName: string, args?: string): Promise<{ activated: true; skillName: string }>; /** Configured MCP servers — GET /mcp/servers. */ listConnectors(): Promise; diff --git a/apps/pythinker-web/src/components/CapabilityMenu.vue b/apps/pythinker-web/src/components/CapabilityMenu.vue new file mode 100644 index 00000000..6a0de6c9 --- /dev/null +++ b/apps/pythinker-web/src/components/CapabilityMenu.vue @@ -0,0 +1,474 @@ + + + + + diff --git a/apps/pythinker-web/src/components/ChatDock.vue b/apps/pythinker-web/src/components/ChatDock.vue index 242931fe..5cdbf9e4 100644 --- a/apps/pythinker-web/src/components/ChatDock.vue +++ b/apps/pythinker-web/src/components/ChatDock.vue @@ -332,8 +332,9 @@ defineExpose({ loadForEdit }); /* Use a different surface so the card radius reads against the page, especially in dark mode; --sh only exists on the modern and pythinker themes, hence the fallback. */ .chat-dock :deep(.composer-card) { - background: var(--panel); - box-shadow: var(--sh, 0 6px 18px rgba(0, 0, 0, 0.28)); + background: color-mix(in srgb, var(--panel) 78%, transparent); + backdrop-filter: blur(16px); + box-shadow: var(--sh, 0 6px 18px color-mix(in srgb, var(--ink) 28%, transparent)); } .chat-dock.align-center { margin-left: auto; margin-right: auto; } diff --git a/apps/pythinker-web/src/components/Composer.vue b/apps/pythinker-web/src/components/Composer.vue index ff1bf16e..7e3abf5e 100644 --- a/apps/pythinker-web/src/components/Composer.vue +++ b/apps/pythinker-web/src/components/Composer.vue @@ -4,6 +4,7 @@ import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'; import { useI18n } from 'vue-i18n'; import SlashMenu from './SlashMenu.vue'; import MentionMenu from './MentionMenu.vue'; +import CapabilityMenu from './CapabilityMenu.vue'; import type { SlashCommand } from '../lib/slashCommands'; import { buildSlashItems, filterCommands, parseSlash } from '../lib/slashCommands'; import type { FileItem } from './MentionMenu.vue'; @@ -1078,6 +1079,8 @@ function selectModel(modelId: string): void { > +