diff --git a/packages/opencode/src/cli/bootstrap.ts b/packages/opencode/src/cli/bootstrap.ts index 0096650224..5274319810 100644 --- a/packages/opencode/src/cli/bootstrap.ts +++ b/packages/opencode/src/cli/bootstrap.ts @@ -5,6 +5,7 @@ import { LSP } from "../lsp" import { Plugin } from "../plugin" import { Share } from "../share/share" import { Snapshot } from "../snapshot" +import { MCP } from "../mcp" export async function bootstrap(input: App.Input, cb: (app: App.Info) => Promise) { return App.provide(input, async (app) => { @@ -15,6 +16,11 @@ export async function bootstrap(input: App.Input, cb: (app: App.Info) => Prom LSP.init() Snapshot.init() + // Initialize MCP servers early so tools are available immediately and first message isn't delayed by MCP booting up + MCP.clients().catch(() => { + // Ignore errors during startup - MCP servers will be retried when needed + }) + return cb(app) }) } diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index 664111fb22..92c5a29cb4 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -155,4 +155,20 @@ export namespace MCP { } return result } + + export async function getToolInfo(): Promise> { + const mcpTools = await tools() + const result: Record = {} + + for (const [toolName, tool] of Object.entries(mcpTools)) { + result[toolName] = { + name: toolName, + description: tool.description || "", + source: "mcp", + defaultEnabled: true, + } + } + + return result + } } diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 2ed65cbb41..8937700d99 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -20,6 +20,8 @@ import { callTui, TuiRoute } from "./tui" import { Permission } from "../permission" import { lazy } from "../util/lazy" import { Agent } from "../agent/agent" +import { ToolRegistry } from "../tool/registry" +import { MCP } from "../mcp" import { Auth } from "../auth" const ERRORS = { @@ -712,6 +714,79 @@ export namespace Server { return c.json(true) }, ) + .get( + "/session/:id/overrides", + describeRoute({ + description: "Get session overrides for all agents (tools and subagents)", + operationId: "session.getAllOverrides", + responses: { + 200: { + description: "Overrides for all agents in the session", + content: { + "application/json": { + schema: resolver( + z.record( + z.string(), + z.object({ + tools: z.record(z.string(), z.boolean()).default({}), + agents: z.record(z.string(), z.boolean()).default({}), + }), + ), + ), + }, + }, + }, + }, + }), + zValidator( + "param", + z.object({ + id: z.string(), + }), + ), + async (c) => { + const { id: sessionID } = c.req.valid("param") + const allOverrides = Session.getAllOverrides(sessionID) + return c.json(allOverrides) + }, + ) + .put( + "/session/:id/:agent/overrides", + describeRoute({ + description: "Set session overrides for a specific agent (tools and subagents)", + operationId: "session.setOverrides", + responses: { + 200: { + description: "Overrides updated successfully", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + }, + }), + zValidator( + "param", + z.object({ + id: z.string(), + agent: z.string(), + }), + ), + zValidator( + "json", + z.object({ + tools: z.record(z.string(), z.boolean()).optional(), + agents: z.record(z.string(), z.boolean()).optional(), + }), + ), + async (c) => { + const { id: sessionID, agent } = c.req.valid("param") + const { tools, agents } = c.req.valid("json") + await Session.setOverrides(sessionID, agent, { tools, agents }) + return c.json(true) + }, + ) .get( "/config/providers", describeRoute({ @@ -964,6 +1039,38 @@ export namespace Server { return c.json(modes) }, ) + .get( + "/app/tools", + describeRoute({ + description: "List all available tools", + operationId: "app.tools", + responses: { + 200: { + description: "List of tools", + content: { + "application/json": { + schema: resolver( + z.record( + z.string(), + z.object({ + name: z.string(), + description: z.string().optional(), + source: z.enum(["builtin", "mcp"]), + defaultEnabled: z.boolean().optional().default(true), + }), + ), + ), + }, + }, + }, + }, + }), + async (c) => { + const builtin = await ToolRegistry.getToolInfo() + const mcp = await MCP.getToolInfo() + return c.json({ ...builtin, ...mcp }) + }, + ) .post( "/tui/append-prompt", describeRoute({ diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts index 2e5e15dd7e..cc6a3c73e1 100644 --- a/packages/opencode/src/session/index.ts +++ b/packages/opencode/src/session/index.ts @@ -147,6 +147,8 @@ export namespace Session { callback: (input: { info: MessageV2.Assistant; parts: MessageV2.Part[] }) => void }[] >() + const subagentOverrides = new Map>>() + const toolOverrides = new Map>>() return { sessions, @@ -154,6 +156,8 @@ export namespace Session { pending, autoCompacting, queued, + subagentOverrides, + toolOverrides, } }, async (state) => { @@ -163,6 +167,69 @@ export namespace Session { }, ) + type OverridesMap = Record> + type Overrides = { tools: Record; agents: Record } + + async function persistOverrides(sessionID: string) { + const tools = state().toolOverrides.get(sessionID) ?? {} + const agents = state().subagentOverrides.get(sessionID) ?? {} + await Storage.writeJSON("session/overrides/" + sessionID, { tools, agents }) + } + + export async function setOverrides(sessionID: string, agentName: string, input: Partial) { + if (input.tools) { + const tools: OverridesMap = state().toolOverrides.get(sessionID) ?? {} + tools[agentName] = input.tools + state().toolOverrides.set(sessionID, tools) + } + if (input.agents) { + const agents: OverridesMap = state().subagentOverrides.get(sessionID) ?? {} + agents[agentName] = input.agents + state().subagentOverrides.set(sessionID, agents) + } + await persistOverrides(sessionID) + } + + export function getOverrides(sessionID: string, agentName: string): Overrides { + const toolsAll = state().toolOverrides.get(sessionID) ?? {} + const agentsAll = state().subagentOverrides.get(sessionID) ?? {} + return { + tools: toolsAll[agentName] ?? {}, + agents: agentsAll[agentName] ?? {}, + } + } + + export function getAllOverrides(sessionID: string): Record { + const toolsAll = state().toolOverrides.get(sessionID) ?? {} + const agentsAll = state().subagentOverrides.get(sessionID) ?? {} + + // Get all unique agent names from both tools and agents overrides + const allAgentNames = new Set([...Object.keys(toolsAll), ...Object.keys(agentsAll)]) + + const result: Record = {} + for (const agentName of allAgentNames) { + result[agentName] = { + tools: toolsAll[agentName] ?? {}, + agents: agentsAll[agentName] ?? {}, + } + } + + return result + } + + async function loadOverrides(sessionID: string) { + try { + const overrides = await Storage.readJSON<{ + tools?: OverridesMap + agents?: OverridesMap + }>("session/overrides/" + sessionID) + state().subagentOverrides.set(sessionID, overrides.agents ?? {}) + state().toolOverrides.set(sessionID, overrides.tools ?? {}) + } catch { + // ignore + } + } + export async function create(parentID?: string) { const result: Info = { id: Identifier.descending("session"), @@ -201,6 +268,8 @@ export namespace Session { } const read = await Storage.readJSON("session/info/" + id) state().sessions.set(id, read) + // Load overrides (tools and agents) from unified file + await loadOverrides(id) return read as Info } @@ -328,9 +397,12 @@ export namespace Session { } await unshare(sessionID).catch(() => {}) await Storage.remove(`session/info/${sessionID}`).catch(() => {}) + await Storage.remove(`session/overrides/${sessionID}`).catch(() => {}) await Storage.removeDir(`session/message/${sessionID}/`).catch(() => {}) state().sessions.delete(sessionID) state().messages.delete(sessionID) + state().subagentOverrides.delete(sessionID) + state().toolOverrides.delete(sessionID) if (emitEvent) { Bus.publish(Event.Deleted, { info: session, @@ -364,6 +436,7 @@ export namespace Session { agent: z.string().optional(), system: z.string().optional(), tools: z.record(z.boolean()).optional(), + agents: z.record(z.boolean()).optional(), parts: z.array( z.discriminatedUnion("type", [ MessageV2.TextPart.omit({ @@ -773,16 +846,60 @@ export namespace Session { const processor = createProcessor(assistantMsg, model.info) + // Load saved overrides for this session/agent + const saved = getOverrides(input.sessionID, input.agent ?? inputAgent) + const savedToolOverrides = saved.tools + const enabledTools = pipe( agent.tools, mergeDeep(await ToolRegistry.enabled(input.providerID, input.modelID, agent)), + mergeDeep(savedToolOverrides), mergeDeep(input.tools ?? {}), ) + + // Store overrides for tools and agents to access, merging with existing ones + if (input.agent) { + const existing = getOverrides(input.sessionID, input.agent) + const next = { + tools: input.tools ? { ...existing.tools, ...input.tools } : undefined, + agents: input.agents ? { ...existing.agents, ...input.agents } : undefined, + } + if (next.tools || next.agents) await setOverrides(input.sessionID, input.agent, next) + } + for (const item of await ToolRegistry.tools(input.providerID, input.modelID)) { if (Wildcard.all(item.id, enabledTools) === false) continue + if (enabledTools[item.id] === false) continue + // Dynamically filter agent descriptions for the task tool based on session overrides + let description = item.description + if (item.id === "task") { + // Load saved subagent overrides for this session/agent + const savedSubagentOverrides = getOverrides(input.sessionID, input.agent ?? inputAgent).agents + const effectiveSubagentOverrides = { ...savedSubagentOverrides, ...(input.agents ?? {}) } + + if (effectiveSubagentOverrides && Object.keys(effectiveSubagentOverrides).length > 0) { + // Get available agents and filter out disabled ones + const availableAgents = await Agent.list().then((x) => + x.filter((a) => a.mode !== "primary" && effectiveSubagentOverrides![a.name] !== false), + ) + // Rebuild the description with only enabled agents + const agentList = availableAgents + .map((a) => `- ${a.name}: ${a.description ?? "This subagent should only be called manually by the user."}`) + .join("\n") + // Replace placeholder with filtered agents + description = description.replace("{agents}", agentList) + } else { + // No filtering needed, show all non-primary agents + const allAgents = await Agent.list().then((x) => x.filter((a) => a.mode !== "primary")) + const agentList = allAgents + .map((a) => `- ${a.name}: ${a.description ?? "This subagent should only be called manually by the user."}`) + .join("\n") + description = description.replace("{agents}", agentList) + } + } tools[item.id] = tool({ id: item.id as any, - description: item.description, + description: description, inputSchema: item.parameters as ZodSchema, async execute(args, options) { await Plugin.trigger( diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 9e8f9638c8..6fed8a13ba 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -94,6 +94,35 @@ export namespace ToolRegistry { return result } + export async function getToolInfo(): Promise> { + const result: Record = {} + // Create a default agent info for tool listing - use permissive defaults + const defaultAgent: Agent.Info = { + name: "default", + mode: "primary", + permission: { + edit: "allow", + bash: { "*": "allow" }, + webfetch: "allow", + }, + tools: {}, + options: {}, + } + const enabled = await ToolRegistry.enabled("", "", defaultAgent) + + for (const tool of ALL) { + const toolId = tool.id + result[toolId] = { + name: toolId, + description: (await tool.init()).description || "", + source: "builtin", + defaultEnabled: enabled[toolId] !== false, + } + } + + return result + } + function sanitizeGeminiParameters(schema: z.ZodTypeAny, visited = new Set()): z.ZodTypeAny { if (!schema || visited.has(schema)) { return schema diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index 0b518b2ba8..679f3640e6 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -8,26 +8,31 @@ import { Identifier } from "../id/id" import { Agent } from "../agent/agent" export const TaskTool = Tool.define("task", async () => { - const agents = await Agent.list().then((x) => x.filter((a) => a.mode !== "primary")) - const description = DESCRIPTION.replace( - "{agents}", - agents - .map((a) => `- ${a.name}: ${a.description ?? "This subagent should only be called manually by the user."}`) - .join("\n"), - ) + // Keep the original description with placeholder for dynamic replacement return { - description, + description: DESCRIPTION, parameters: z.object({ description: z.string().describe("A short (3-5 words) description of the task"), prompt: z.string().describe("The task for the agent to perform"), subagent_type: z.string().describe("The type of specialized agent to use for this task"), }), async execute(params, ctx) { + // Check if the requested agent is disabled by session overrides + if (ctx.sessionID && ctx.agent) { + const overrides = Session.getOverrides(ctx.sessionID, ctx.agent) + // Use same logic as autocomplete: if explicitly set to false, block it + if (overrides.agents[params.subagent_type] === false) { + throw new Error(`Agent ${params.subagent_type} is disabled in the current session`) + } + } + + // Check if the requested agent exists + const agent = await Agent.get(params.subagent_type) + if (!agent) throw new Error(`Unknown agent type: ${params.subagent_type} is not a valid agent type`) + const session = await Session.create(ctx.sessionID) const msg = await Session.getMessage(ctx.sessionID, ctx.messageID) if (msg.info.role !== "assistant") throw new Error("Not an assistant message") - const agent = await Agent.get(params.subagent_type) - if (!agent) throw new Error(`Unknown agent type: ${params.subagent_type} is not a valid agent type`) const messageID = Identifier.ascending("message") const parts: Record = {} const unsub = Bus.subscribe(MessageV2.Event.PartUpdated, async (evt) => { diff --git a/packages/sdk/go/session.go b/packages/sdk/go/session.go index 76a9d46fb2..597ae6bb88 100644 --- a/packages/sdk/go/session.go +++ b/packages/sdk/go/session.go @@ -2311,6 +2311,7 @@ type SessionChatParams struct { Parts param.Field[[]SessionChatParamsPartUnion] `json:"parts,required"` ProviderID param.Field[string] `json:"providerID,required"` Agent param.Field[string] `json:"agent"` + Agents param.Field[map[string]bool] `json:"agents"` MessageID param.Field[string] `json:"messageID"` System param.Field[string] `json:"system"` Tools param.Field[map[string]bool] `json:"tools"` diff --git a/packages/tui/internal/app/app.go b/packages/tui/internal/app/app.go index 023b799d5f..bb43ff26ca 100644 --- a/packages/tui/internal/app/app.go +++ b/packages/tui/internal/app/app.go @@ -2,11 +2,14 @@ package app import ( "context" + "encoding/json" "fmt" + "net/http" "os" "path/filepath" "slices" "strings" + "time" "log/slog" @@ -56,6 +59,96 @@ func (a *App) Agent() *opencode.Agent { return &a.Agents[a.AgentIndex] } +// Add simple session resource overrides storage - now per agent per session +type sessionResourceOverrides struct { + // Map from agent name to their tool/agent overrides + AgentOverrides map[string]map[string]map[string]bool `json:"agentOverrides,omitempty"` // agent -> type -> name -> enabled +} + +var sessionOverrides = make(map[string]*sessionResourceOverrides) + +// ClearHomeScreenOverrides clears any stored overrides for the home screen +func (a *App) ClearHomeScreenOverrides() { + delete(sessionOverrides, "") +} + +// TransferHomeScreenOverrides copies overrides from home screen ("" session) to a new session +func (a *App) TransferHomeScreenOverrides(newSessionID string) { + if homeOverrides, exists := sessionOverrides[""]; exists { + // Deep copy the overrides structure + newOverrides := &sessionResourceOverrides{ + AgentOverrides: make(map[string]map[string]map[string]bool), + } + for agent, agentOverrides := range homeOverrides.AgentOverrides { + newOverrides.AgentOverrides[agent] = make(map[string]map[string]bool) + for overrideType, overrides := range agentOverrides { + newAgentOverrides := make(map[string]bool) + for k, v := range overrides { + newAgentOverrides[k] = v + } + newOverrides.AgentOverrides[agent][overrideType] = newAgentOverrides + } + } + sessionOverrides[newSessionID] = newOverrides + + } +} + +// GetSessionToolOverrides returns tool overrides for the given agent in the current session +func (a *App) GetSessionToolOverrides(agentName string) map[string]bool { + if overrides, exists := sessionOverrides[a.Session.ID]; exists && overrides.AgentOverrides != nil { + if agentMap, exists := overrides.AgentOverrides[agentName]; exists { + if toolOverrides, exists := agentMap["tool"]; exists { + return toolOverrides + } + } + } + return make(map[string]bool) +} + +// SetSessionToolOverrides sets tool overrides for the given agent in the current session +func (a *App) SetSessionToolOverrides(agentName string, overrides map[string]bool) { + if sessionOverrides[a.Session.ID] == nil { + sessionOverrides[a.Session.ID] = &sessionResourceOverrides{ + AgentOverrides: make(map[string]map[string]map[string]bool), + } + } + if sessionOverrides[a.Session.ID].AgentOverrides[agentName] == nil { + sessionOverrides[a.Session.ID].AgentOverrides[agentName] = make(map[string]map[string]bool) + } + sessionOverrides[a.Session.ID].AgentOverrides[agentName]["tool"] = overrides +} + +// GetSessionSubagentOverrides returns subagent overrides for the given agent in the current session +func (a *App) GetSessionSubagentOverrides(agentName string) map[string]bool { + if overrides, exists := sessionOverrides[a.Session.ID]; exists && overrides.AgentOverrides != nil { + if agentMap, exists := overrides.AgentOverrides[agentName]; exists { + if subagentOverrides, exists := agentMap["agent"]; exists { + return subagentOverrides + } + } + } + return make(map[string]bool) +} + +// SetSessionSubagentOverrides sets subagent overrides for the given agent in the current session +func (a *App) SetSessionSubagentOverrides(agentName string, overrides map[string]bool) { + if sessionOverrides[a.Session.ID] == nil { + sessionOverrides[a.Session.ID] = &sessionResourceOverrides{ + AgentOverrides: make(map[string]map[string]map[string]bool), + } + } + if sessionOverrides[a.Session.ID].AgentOverrides[agentName] == nil { + sessionOverrides[a.Session.ID].AgentOverrides[agentName] = make(map[string]map[string]bool) + } + sessionOverrides[a.Session.ID].AgentOverrides[agentName]["agent"] = overrides +} + +// HasActiveSession returns true if there's an active session +func (a *App) HasActiveSession() bool { + return a.Session != nil && a.Session.ID != "" +} + type SessionCreatedMsg = struct { Session *opencode.Session } @@ -93,6 +186,23 @@ type PermissionRespondedToMsg struct { Response opencode.SessionPermissionRespondParamsResponse } +// ToolsUpdatedMsg is emitted when tool overrides for an agent are updated via the dialog +// Overrides contains only explicit deviations (true/false) from defaults for that agent +// Agent is the agent name whose overrides were changed +// When Overrides is empty the entry for the agent should be removed +// The dialog implementation will compute these overrides prior to emitting the message +// NOTE: Overrides are session-scoped and NOT persisted to disk. +type ToolsUpdatedMsg struct { + Agent string + Overrides map[string]bool +} + +// AgentsUpdatedMsg is emitted when subagent overrides are updated via the dialog +// (kept as AgentsUpdatedMsg for backwards compatibility, but represents subagent changes) +type AgentsUpdatedMsg struct { + Overrides map[string]bool +} + func New( ctx context.Context, version string, @@ -720,12 +830,15 @@ func (a *App) CreateSession(ctx context.Context) (*opencode.Session, error) { func (a *App) SendPrompt(ctx context.Context, prompt Prompt) (*App, tea.Cmd) { var cmds []tea.Cmd + var isNewSession bool + if a.Session.ID == "" { session, err := a.CreateSession(ctx) if err != nil { return a, toast.NewErrorToast(err.Error()) } a.Session = session + isNewSession = true cmds = append(cmds, util.CmdHandler(SessionCreatedMsg{Session: session})) } @@ -735,13 +848,40 @@ func (a *App) SendPrompt(ctx context.Context, prompt Prompt) (*App, tea.Cmd) { a.Messages = append(a.Messages, message) cmds = append(cmds, func() tea.Msg { - _, err := a.Client.Session.Chat(ctx, a.Session.ID, opencode.SessionChatParams{ + params := opencode.SessionChatParams{ ProviderID: opencode.F(a.Provider.ID), ModelID: opencode.F(a.Model.ID), Agent: opencode.F(a.Agent().Name), MessageID: opencode.F(messageID), Parts: opencode.F(message.ToSessionChatParams()), - }) + } + + if ov := a.GetSessionToolOverrides(a.Agent().Name); len(ov) > 0 { + params.Tools = opencode.F(ov) + } + if av := a.GetSessionSubagentOverrides(a.Agent().Name); len(av) > 0 { + params.Agents = opencode.F(av) + } + + // If this is a new session, save current overrides to session storage + if isNewSession { + currentAgent := a.Agent().Name + toolOverrides := a.GetSessionToolOverrides(currentAgent) + agentOverrides := a.GetSessionSubagentOverrides(currentAgent) + + if len(toolOverrides) > 0 { + go func() { + _ = a.SaveSessionToolOverrides(context.Background(), currentAgent, toolOverrides) + }() + } + if len(agentOverrides) > 0 { + go func() { + _ = a.SaveSessionSubagentOverrides(context.Background(), currentAgent, agentOverrides) + }() + } + } + + _, err := a.Client.Session.Chat(ctx, a.Session.ID, params) if err != nil { errormsg := fmt.Sprintf("failed to send message: %v", err) slog.Error(errormsg) @@ -869,6 +1009,218 @@ func (a *App) ListProviders(ctx context.Context) ([]opencode.Provider, error) { return providers.Providers, nil } +type ToolInfo struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + Source string `json:"source"` // "builtin" or "mcp" + DefaultEnabled *bool `json:"defaultEnabled"` // pointer to allow nil detection +} + +type AgentInfo struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + Mode string `json:"mode"` // "subagent", "primary", "all" +} + +// IsToolDefaultEnabled returns whether a tool is enabled by default +func IsToolDefaultEnabled(tool ToolInfo) bool { + if tool.DefaultEnabled != nil { + return *tool.DefaultEnabled + } + return true +} + +func (a *App) ListTools(ctx context.Context) (map[string]ToolInfo, error) { + u := os.Getenv("OPENCODE_SERVER") + if u == "" { + return nil, fmt.Errorf("OPENCODE_SERVER environment variable not set") + } + u = strings.TrimSuffix(u, "/") + "/app/tools" + ctx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) + if err != nil { + return nil, fmt.Errorf("create tools request: %w", err) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("fetch tools: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("tools API status %d", resp.StatusCode) + } + var tools map[string]ToolInfo + if err := json.NewDecoder(resp.Body).Decode(&tools); err != nil { + return nil, fmt.Errorf("decode tools: %w", err) + } + return tools, nil +} + +func (a *App) ListAgents(ctx context.Context) ([]AgentInfo, error) { + u := os.Getenv("OPENCODE_SERVER") + if u == "" { + return nil, fmt.Errorf("OPENCODE_SERVER environment variable not set") + } + u = strings.TrimSuffix(u, "/") + "/agent" + ctx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) + if err != nil { + return nil, fmt.Errorf("create agents request: %w", err) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("fetch agents: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("agents API status %d", resp.StatusCode) + } + var agents []AgentInfo + if err := json.NewDecoder(resp.Body).Decode(&agents); err != nil { + return nil, fmt.Errorf("decode agents: %w", err) + } + return agents, nil +} + +// LoadSessionOverrides loads tool and subagent overrides for a specific session +func (a *App) LoadSessionOverrides(ctx context.Context, sessionID string) error { + u := os.Getenv("OPENCODE_SERVER") + if u == "" { + return fmt.Errorf("OPENCODE_SERVER environment variable not set") + } + + // Load all overrides for this session in one call + overridesURL := strings.TrimSuffix(u, "/") + "/session/" + sessionID + "/overrides" + ctx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, overridesURL, nil) + if err != nil { + return fmt.Errorf("create overrides request: %w", err) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return fmt.Errorf("fetch overrides: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusOK { + // Response contains all agent overrides: { agentName: { tools: {...}, agents: {...} } } + var allOverrides map[string]struct { + Tools map[string]bool `json:"tools"` + Agents map[string]bool `json:"agents"` + } + if err := json.NewDecoder(resp.Body).Decode(&allOverrides); err != nil { + return fmt.Errorf("decode overrides: %w", err) + } + + // Initialize session overrides structure + if sessionOverrides[sessionID] == nil { + sessionOverrides[sessionID] = &sessionResourceOverrides{ + AgentOverrides: make(map[string]map[string]map[string]bool), + } + } + + // Load overrides for each agent + for agentName, payload := range allOverrides { + if sessionOverrides[sessionID].AgentOverrides[agentName] == nil { + sessionOverrides[sessionID].AgentOverrides[agentName] = make(map[string]map[string]bool) + } + if payload.Tools != nil && len(payload.Tools) > 0 { + sessionOverrides[sessionID].AgentOverrides[agentName]["tool"] = payload.Tools + } + if payload.Agents != nil && len(payload.Agents) > 0 { + sessionOverrides[sessionID].AgentOverrides[agentName]["agent"] = payload.Agents + } + } + } + + return nil +} + +// SaveSessionToolOverrides saves tool overrides for a specific agent to the server +func (a *App) SaveSessionToolOverrides(ctx context.Context, agentName string, overrides map[string]bool) error { + u := os.Getenv("OPENCODE_SERVER") + if u == "" { + return fmt.Errorf("OPENCODE_SERVER environment variable not set") + } + + // Send combined overrides payload to unified endpoint + url := strings.TrimSuffix(u, "/") + "/session/" + a.Session.ID + "/" + agentName + "/overrides" + bodyData := map[string]interface{}{ + "tools": overrides, + } + + body, err := json.Marshal(bodyData) + if err != nil { + return fmt.Errorf("marshal overrides: %w", err) + } + + ctx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, http.MethodPut, url, strings.NewReader(string(body))) + if err != nil { + return fmt.Errorf("create overrides request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return fmt.Errorf("save overrides: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("server returned status %d", resp.StatusCode) + } + + return nil +} + +// SaveSessionSubagentOverrides saves subagent overrides for a specific agent to the server +func (a *App) SaveSessionSubagentOverrides(ctx context.Context, agentName string, overrides map[string]bool) error { + u := os.Getenv("OPENCODE_SERVER") + if u == "" { + return fmt.Errorf("OPENCODE_SERVER environment variable not set") + } + + // Send combined overrides payload to unified endpoint + url := strings.TrimSuffix(u, "/") + "/session/" + a.Session.ID + "/" + agentName + "/overrides" + bodyData := map[string]interface{}{ + "agents": overrides, + } + + body, err := json.Marshal(bodyData) + if err != nil { + return fmt.Errorf("marshal overrides: %w", err) + } + + ctx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, http.MethodPut, url, strings.NewReader(string(body))) + if err != nil { + return fmt.Errorf("create overrides request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return fmt.Errorf("save overrides: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("server returned status %d", resp.StatusCode) + } + + return nil +} + +// Removed getFallbackTools() - server is the single source of truth // func (a *App) loadCustomKeybinds() { // // } diff --git a/packages/tui/internal/app/state.go b/packages/tui/internal/app/state.go index 0e4010becd..700bf900c1 100644 --- a/packages/tui/internal/app/state.go +++ b/packages/tui/internal/app/state.go @@ -40,6 +40,9 @@ type State struct { MessageHistory []Prompt `toml:"message_history"` ShowToolDetails *bool `toml:"show_tool_details"` ShowThinkingBlocks *bool `toml:"show_thinking_blocks"` + // Persistent tool/agent preferences per agent + ToolOverrides map[string]map[string]bool `toml:"tool_overrides,omitempty"` // agent -> tool -> enabled + AgentOverrides map[string]map[string]bool `toml:"agent_overrides,omitempty"` // agent -> subagent -> enabled } func NewState() *State { @@ -50,6 +53,8 @@ func NewState() *State { RecentlyUsedModels: make([]ModelUsage, 0), RecentlyUsedAgents: make([]AgentUsage, 0), MessageHistory: make([]Prompt, 0), + ToolOverrides: make(map[string]map[string]bool), + AgentOverrides: make(map[string]map[string]bool), } } diff --git a/packages/tui/internal/commands/command.go b/packages/tui/internal/commands/command.go index fff5475435..5f77a0b27f 100644 --- a/packages/tui/internal/commands/command.go +++ b/packages/tui/internal/commands/command.go @@ -123,6 +123,7 @@ const ( ModelListCommand CommandName = "model_list" AgentListCommand CommandName = "agent_list" ModelCycleRecentCommand CommandName = "model_cycle_recent" + ToolListCommand CommandName = "tool_list" ThemeListCommand CommandName = "theme_list" FileListCommand CommandName = "file_list" FileCloseCommand CommandName = "file_close" @@ -269,6 +270,12 @@ func LoadFromConfig(config *opencode.Config) CommandRegistry { Description: "cycle recent models", Keybindings: parseBindings("f2"), }, + { + Name: ToolListCommand, + Description: "toggle tools", + Keybindings: parseBindings("o"), + Trigger: []string{"tools"}, + }, { Name: ThemeListCommand, Description: "list themes", diff --git a/packages/tui/internal/completions/agents.go b/packages/tui/internal/completions/agents.go index c39fe30365..ae8da413a3 100644 --- a/packages/tui/internal/completions/agents.go +++ b/packages/tui/internal/completions/agents.go @@ -48,6 +48,11 @@ func (cg *agentsContextGroup) GetChildEntries( if agent.Mode == opencode.AgentModePrimary { continue } + // Check if agent is disabled by effective overrides for the current agent + currentAgentOverrides := cg.app.GetSessionSubagentOverrides(cg.app.Agent().Name) + if enabled, exists := currentAgentOverrides[agent.Name]; exists && !enabled { + continue + } displayFunc := func(s styles.Style) string { t := theme.CurrentTheme() diff --git a/packages/tui/internal/components/dialog/items.go b/packages/tui/internal/components/dialog/items.go new file mode 100644 index 0000000000..b9e8340345 --- /dev/null +++ b/packages/tui/internal/components/dialog/items.go @@ -0,0 +1,146 @@ +package dialog + +import ( + "fmt" + "github.com/sst/opencode/internal/app" + "github.com/sst/opencode/internal/styles" + "github.com/sst/opencode/internal/theme" +) + +// ResourceItem is a unified type for both tools and agents in dialogs +type ResourceItem struct { + Name string + DisplayName string + Type string // "tool" or "agent" + Source string // "builtin"/"mcp" for tools, "subagent"/"primary"/"all" for agents + Enabled bool + DefaultEnabled bool + Overridden bool // differs from default + IsSelectable bool + Mode string // for agents: "subagent", "primary", or "all" + Description string // optional description + IsToggleMode bool // true for tool/agent toggles, false for agent selection +} + +// NewToolResourceItem creates a ResourceItem for tools +func NewToolResourceItem(name, source string, enabled, defaultEnabled bool) ResourceItem { + return ResourceItem{ + Name: name, + DisplayName: name, + Type: "tool", + Source: source, + Enabled: enabled, + DefaultEnabled: defaultEnabled, + Overridden: enabled != defaultEnabled, + IsSelectable: true, + IsToggleMode: true, + } +} + +// NewAgentResourceItem creates a ResourceItem for agents (for selection dialogs) +func NewAgentResourceItem(name, description, mode string, isToggleMode bool) ResourceItem { + displayName := name + if description == "" && mode != "" { + description = fmt.Sprintf("(%s)", mode) + } + + return ResourceItem{ + Name: name, + DisplayName: displayName, + Type: "agent", + Source: mode, + Mode: mode, + Description: description, + IsSelectable: true, + IsToggleMode: isToggleMode, + } +} + +// NewAgentToggleResourceItem creates a ResourceItem for agent toggle functionality +func NewAgentToggleResourceItem(name, mode string, enabled, defaultEnabled bool) ResourceItem { + return ResourceItem{ + Name: name, + DisplayName: name, + Type: "agent", + Source: mode, + Mode: mode, + Enabled: enabled, + DefaultEnabled: defaultEnabled, + Overridden: enabled != defaultEnabled, + IsSelectable: true, + IsToggleMode: true, + } +} + +func (r ResourceItem) Render(selected bool, width int, baseStyle styles.Style) string { + theme := theme.CurrentTheme() + + itemStyle := baseStyle. + Background(theme.BackgroundPanel()). + Foreground(theme.Text()) + + if selected { + itemStyle = itemStyle.Foreground(theme.Primary()) + } else if r.Overridden && r.IsToggleMode { + // non-selected overridden items get warning color in toggle mode + itemStyle = itemStyle.Foreground(theme.Warning()) + } + + // For selection dialogs (agent selection), show description + if r.Type == "agent" && !r.IsToggleMode { + return r.renderSelection(itemStyle, baseStyle, width) + } + + // For toggle dialogs (tools and agent toggles), show toggle state + if r.IsToggleMode { + toggleIndicator := "[ ]" + if r.Enabled { + toggleIndicator = "[✓]" + } + text := fmt.Sprintf("%s %s", toggleIndicator, r.DisplayName) + return itemStyle.PaddingLeft(1).Render(text) + } + + // Default rendering for other cases + return itemStyle.PaddingLeft(1).Render(r.DisplayName) +} + +func (r ResourceItem) renderSelection(itemStyle, baseStyle styles.Style, width int) string { + descStyle := baseStyle. + Foreground(theme.CurrentTheme().TextMuted()). + Background(theme.CurrentTheme().BackgroundPanel()) + + // Calculate available width (accounting for padding and margins) + availableWidth := width - 2 // Account for left padding + + agentName := r.DisplayName + description := r.Description + separator := " - " + + // Calculate how much space we have for the description + nameAndSeparatorLength := len(agentName) + len(separator) + descriptionMaxLength := availableWidth - nameAndSeparatorLength + + // Truncate description if it's too long + if len(description) > descriptionMaxLength && descriptionMaxLength > 3 { + description = description[:descriptionMaxLength-3] + "..." + } + + namePart := itemStyle.Render(agentName) + descPart := descStyle.Render(separator + description) + combinedText := namePart + descPart + + return baseStyle.PaddingLeft(1).Render(combinedText) +} + +func (r ResourceItem) Selectable() bool { + return r.IsSelectable +} + +// IsToolDefaultEnabled returns whether a tool is enabled by default +func IsToolDefaultEnabled(tool app.ToolInfo) bool { + if tool.DefaultEnabled != nil { + return *tool.DefaultEnabled + } + return true +} diff --git a/packages/tui/internal/components/dialog/search.go b/packages/tui/internal/components/dialog/search.go index b8fefd8b90..8b03380eef 100644 --- a/packages/tui/internal/components/dialog/search.go +++ b/packages/tui/internal/components/dialog/search.go @@ -237,6 +237,16 @@ func (s *SearchDialog) GetQuery() string { return s.textInput.Value() } +// GetSelectedItem returns the currently selected item and its index +func (s *SearchDialog) GetSelectedItem() (list.Item, int) { + return s.list.GetSelectedItem() +} + +// SetSelectedIndex sets the selected index +func (s *SearchDialog) SetSelectedIndex(idx int) { + s.list.SetSelectedIndex(idx) +} + // SetQuery sets the search query func (s *SearchDialog) SetQuery(query string) { s.textInput.SetValue(query) diff --git a/packages/tui/internal/components/dialog/tools.go b/packages/tui/internal/components/dialog/tools.go new file mode 100644 index 0000000000..2184c5e5a7 --- /dev/null +++ b/packages/tui/internal/components/dialog/tools.go @@ -0,0 +1,533 @@ +package dialog + +import ( + "context" + "fmt" + "sort" + + tea "github.com/charmbracelet/bubbletea/v2" + "github.com/lithammer/fuzzysearch/fuzzy" + "github.com/sst/opencode-sdk-go" + "github.com/sst/opencode/internal/app" + "github.com/sst/opencode/internal/components/list" + "github.com/sst/opencode/internal/components/modal" + "github.com/sst/opencode/internal/layout" + "github.com/sst/opencode/internal/util" +) + +const ( + numVisibleResources = 20 + minResourceDialogWidth = 40 +) + +// ResourceDialog interface for unified resource management +type ResourceDialog interface{ layout.Modal } + +type resourceDialog struct { + app *app.App + resourceType string // "tool" or "agent" + allResources []ResourceItem + modal *modal.Modal + searchDialog *SearchDialog + dialogWidth int + width int + height int +} + +func (d *resourceDialog) Init() tea.Cmd { + if d.searchDialog != nil { + return d.searchDialog.Init() + } + return nil +} + +func (d *resourceDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case SearchSelectionMsg: + if item, ok := msg.Item.(ResourceItem); ok { + if item.Type == "agent" && !item.IsToggleMode { + // Agent selection mode + agents := d.app.Agents + for _, agent := range agents { + if agent.Name == item.Name { + return d, tea.Sequence( + util.CmdHandler(modal.CloseModalMsg{}), + util.CmdHandler(app.AgentSelectedMsg{AgentName: agent.Name}), + ) + } + } + } else { + // Toggle mode for tools or agent overrides + d.toggleResource(item) + return d, d.emitUpdateMessage(item) + } + } + return d, nil + case SearchCancelledMsg: + return d, util.CmdHandler(modal.CloseModalMsg{}) + case SearchQueryChangedMsg: + items := d.buildItems(msg.Query) + if d.searchDialog != nil { + d.searchDialog.SetItems(items) + } + return d, nil + case tea.WindowSizeMsg: + d.width = msg.Width + d.height = msg.Height + if d.searchDialog != nil { + d.searchDialog.SetWidth(d.dialogWidth) + d.searchDialog.SetHeight(msg.Height) + } + case tea.KeyPressMsg: + switch msg.String() { + case "esc": + return d, util.CmdHandler(modal.CloseModalMsg{}) + case "tab": + if d.resourceType == "tool" { + // Remember current selection before switching agents + var selectedName string + if d.searchDialog != nil { + if selectedItem, _ := d.searchDialog.GetSelectedItem(); selectedItem != nil { + if res, ok := selectedItem.(ResourceItem); ok { + selectedName = res.Name + } + } + } + + // Cycle to next agent (forward) + updated, _ := d.app.SwitchAgent() + d.app = updated + d.setupAllResources() + + // Try to restore a reasonable selection after rebuild + if selectedName != "" && d.searchDialog != nil { + curQuery := d.searchDialog.GetQuery() + items := d.buildItems(curQuery) + d.searchDialog.SetItems(items) + d.restoreSelectionByName(selectedName, items) + } + } + return d, nil + } + } + + if d.searchDialog != nil { + updatedDialog, cmd := d.searchDialog.Update(msg) + d.searchDialog = updatedDialog.(*SearchDialog) + return d, cmd + } + return d, nil +} + +func (d *resourceDialog) View() string { + if d.searchDialog == nil { + return "Loading..." + } + return d.searchDialog.View() +} + +func (d *resourceDialog) Render(background string) string { + return d.modal.Render(d.View(), background) +} + +func (d *resourceDialog) Close() tea.Cmd { + return nil +} + +func (d *resourceDialog) toggleResource(item ResourceItem) { + // Remember the name of the currently selected item for restoration + selectedName := item.Name + + for i := range d.allResources { + if d.allResources[i].Name == item.Name && d.allResources[i].Type == item.Type { + d.allResources[i].Enabled = !d.allResources[i].Enabled + d.allResources[i].Overridden = d.allResources[i].Enabled != d.allResources[i].DefaultEnabled + break + } + } + + // Rebuild visual list and restore selection + curQuery := d.searchDialog.GetQuery() + newItems := d.buildItems(curQuery) + d.searchDialog.SetItems(newItems) + + // Restore selection by finding the item with the same name + d.restoreSelectionByName(selectedName, newItems) +} + +// restoreSelectionByName attempts to restore selection to an item with the given name +func (d *resourceDialog) restoreSelectionByName(name string, items []list.Item) { + if name == "" { + return + } + + for i, item := range items { + switch v := item.(type) { + case ResourceItem: + if v.Name == name { + d.searchDialog.SetSelectedIndex(i) + return + } + } + } +} + +func (d *resourceDialog) emitUpdateMessage(item ResourceItem) tea.Cmd { + agent := d.app.Agent() + + if item.Type == "tool" { + overrides := make(map[string]bool) + for _, res := range d.allResources { + if res.Type == "tool" && res.Overridden { + overrides[res.Name] = res.Enabled + } + } + return util.CmdHandler(app.ToolsUpdatedMsg{Agent: agent.Name, Overrides: overrides}) + } else { + overrides := make(map[string]bool) + for _, res := range d.allResources { + if res.Type == "agent" && res.IsToggleMode { + overrides[res.Name] = res.Enabled + } + } + return util.CmdHandler(app.AgentsUpdatedMsg{Overrides: overrides}) + } +} + +func (d *resourceDialog) setupAllResources() { + + ctx := context.Background() + agent := d.app.Agent() + + // Always initialize allResources to prevent nil issues + d.allResources = make([]ResourceItem, 0) + + if d.resourceType == "tool" { + d.setupToolResources(ctx, agent) + } else { + d.setupAgentResources(ctx, agent) + } + + // Calculate optimal width + d.dialogWidth = minResourceDialogWidth + maxWidth := 0 + for _, res := range d.allResources { + itemWidth := len(res.DisplayName) + 10 // padding + toggle indicator + if itemWidth > maxWidth { + maxWidth = itemWidth + } + } + if maxWidth > d.dialogWidth { + d.dialogWidth = maxWidth + } + + // Always initialize search dialog, even if resources failed to load + if d.searchDialog == nil { + title := fmt.Sprintf("Search %ss...", d.resourceType) + d.searchDialog = NewSearchDialog(title, numVisibleResources) + if d.searchDialog == nil { + return + } + } else { + } + + d.searchDialog.SetWidth(d.dialogWidth) + + // Build initial display list + items := d.buildItems("") + d.searchDialog.SetItems(items) +} + +func (d *resourceDialog) setupToolResources(ctx context.Context, agent *opencode.Agent) { + + // Initialize allResources if not already done + if d.allResources == nil { + d.allResources = make([]ResourceItem, 0) + } + + // Get tools + availableTools, err := d.app.ListTools(ctx) + if err != nil { + // Add some dummy tools to test the dialog + d.allResources = append(d.allResources, []ResourceItem{ + NewToolResourceItem("bash", "builtin", true, true), + NewToolResourceItem("edit", "builtin", true, true), + NewToolResourceItem("read", "builtin", true, true), + NewToolResourceItem("write", "builtin", false, true), + }...) + } else { + + toolOverrides := d.app.GetSessionToolOverrides(agent.Name) + + // Build tool items with current state + toolKeys := make([]string, 0, len(availableTools)) + for k, toolInfo := range availableTools { + if k != "invalid" && (toolInfo.DefaultEnabled == nil || *toolInfo.DefaultEnabled) { + toolKeys = append(toolKeys, k) + } + } + sort.Strings(toolKeys) + + for _, toolName := range toolKeys { + toolInfo := availableTools[toolName] + defaultEnabled := IsToolDefaultEnabled(toolInfo) + if agentSetting, exists := agent.Tools[toolName]; exists { + defaultEnabled = agentSetting + } + enabled := defaultEnabled + if override, exists := toolOverrides[toolName]; exists { + enabled = override + } + + d.allResources = append(d.allResources, NewToolResourceItem( + toolName, toolInfo.Source, enabled, defaultEnabled, + )) + } + } + + // Add subagents as toggles (this was missing!) + availableAgents, err := d.app.ListAgents(ctx) + if err != nil { + // Add some dummy agents for testing + d.allResources = append(d.allResources, []ResourceItem{ + NewAgentToggleResourceItem("general", "subagent", true, true), + NewAgentToggleResourceItem("docs", "subagent", true, true), + }...) + } else { + + agentOverrides := d.app.GetSessionSubagentOverrides(agent.Name) + + // Add subagents as toggles + for _, agentInfo := range availableAgents { + if agentInfo.Mode == "primary" { + continue // Skip primary agents + } + + defaultEnabled := true // subagents are enabled by default + enabled := defaultEnabled + if override, exists := agentOverrides[agentInfo.Name]; exists { + enabled = override + } + + d.allResources = append(d.allResources, NewAgentToggleResourceItem( + agentInfo.Name, agentInfo.Mode, enabled, defaultEnabled, + )) + } + } + +} + +func (d *resourceDialog) setupAgentResources(ctx context.Context, agent *opencode.Agent) { + + // Initialize allResources if not already done + if d.allResources == nil { + d.allResources = make([]ResourceItem, 0) + } + + // For now, add some dummy agents to test the dialog + d.allResources = append(d.allResources, []ResourceItem{ + NewAgentResourceItem("general", "General-purpose agent", "subagent", false), + NewAgentResourceItem("docs", "Documentation agent", "subagent", false), + }...) + + // Try to get real agents but don't fail if it doesn't work + availableAgents, err := d.app.ListAgents(ctx) + if err != nil { + return + } + + // Clear dummy data and use real data + d.allResources = make([]ResourceItem, 0) + + // For agent dialog, show selection mode (not toggle mode) + for _, agentInfo := range availableAgents { + if agentInfo.Mode == "primary" { + continue // Skip primary agents in selection + } + + d.allResources = append(d.allResources, NewAgentResourceItem( + agentInfo.Name, agentInfo.Description, agentInfo.Mode, false, // false = selection mode + )) + } +} + +func (d *resourceDialog) buildItems(query string) []list.Item { + if query == "" { + return d.buildGroupedItems() + } + return d.buildSearchItems(query) +} + +func (d *resourceDialog) buildGroupedItems() []list.Item { + var items []list.Item + + if d.resourceType == "tool" { + // Group by source/type with proper ordering: builtin tools, mcp tools, then agents + + // 1. Built-in Tools + builtinTools := make([]ResourceItem, 0) + mcpTools := make([]ResourceItem, 0) + agents := make([]ResourceItem, 0) + + for _, res := range d.allResources { + switch { + case res.Type == "tool" && res.Source == "builtin": + builtinTools = append(builtinTools, res) + case res.Type == "tool" && res.Source == "mcp": + mcpTools = append(mcpTools, res) + case res.Type == "agent": + agents = append(agents, res) + } + } + + // Sort each category alphabetically within itself + sort.Slice(builtinTools, func(i, j int) bool { + return builtinTools[i].Name < builtinTools[j].Name + }) + sort.Slice(mcpTools, func(i, j int) bool { + return mcpTools[i].Name < mcpTools[j].Name + }) + sort.Slice(agents, func(i, j int) bool { + return agents[i].Name < agents[j].Name + }) + + // Add groups in order with headers + if len(builtinTools) > 0 { + items = append(items, list.HeaderItem("Built-in Tools")) + for _, tool := range builtinTools { + items = append(items, tool) + } + } + + if len(mcpTools) > 0 { + items = append(items, list.HeaderItem("MCP Tools")) + for _, tool := range mcpTools { + items = append(items, tool) + } + } + + if len(agents) > 0 { + items = append(items, list.HeaderItem("Subagents")) + for _, agent := range agents { + items = append(items, agent) + } + } + } else { + // For agent selection dialog (not tools dialog), just list agents + for _, res := range d.allResources { + items = append(items, res) + } + } + + return items +} + +func (d *resourceDialog) buildSearchItems(query string) []list.Item { + var matches []ResourceItem + + for _, res := range d.allResources { + // Check fuzzy match against multiple fields + matched := false + + // Match against name and display name + if fuzzy.MatchFold(query, res.Name) || fuzzy.MatchFold(query, res.DisplayName) { + matched = true + } + + // Match against category names + if !matched { + categoryName := "" + switch { + case res.Type == "tool" && res.Source == "builtin": + categoryName = "builtin tools" + case res.Type == "tool" && res.Source == "mcp": + categoryName = "mcp tools" + case res.Type == "agent": + categoryName = "subagents agents" + } + + if fuzzy.MatchFold(query, categoryName) || fuzzy.MatchFold(query, res.Source) { + matched = true + } + } + + // Match against description if available + if !matched && res.Description != "" { + if fuzzy.MatchFold(query, res.Description) { + matched = true + } + } + + if matched { + matches = append(matches, res) + } + } + + // Sort matches by category first, then by name within category + sort.Slice(matches, func(i, j int) bool { + resI, resJ := matches[i], matches[j] + + // First, sort by type and source priority + orderI := getResourceOrder(resI) + orderJ := getResourceOrder(resJ) + + if orderI != orderJ { + return orderI < orderJ + } + + // Within same category, sort by name + return resI.Name < resJ.Name + }) + + // Convert to list items + items := make([]list.Item, len(matches)) + for i, match := range matches { + items[i] = match + } + + return items +} + +// Helper function to determine sort order for resources +func getResourceOrder(res ResourceItem) int { + switch { + case res.Type == "tool" && res.Source == "builtin": + return 1 + case res.Type == "tool" && res.Source == "mcp": + return 2 + case res.Type == "agent": + return 3 + default: + return 4 + } +} + +// Factory functions +func NewToolsDialog(app *app.App) ResourceDialog { + dialog := &resourceDialog{ + app: app, + resourceType: "tool", + } + + // Setup resources immediately in constructor, like other dialogs + dialog.setupAllResources() + + dialog.modal = modal.New(modal.WithTitle("Toolbox")) + return dialog +} + +func NewAgentsDialog(app *app.App) ResourceDialog { + dialog := &resourceDialog{ + app: app, + resourceType: "agent", + } + + // Setup resources immediately in constructor, like other dialogs + dialog.setupAllResources() + + dialog.modal = modal.New(modal.WithTitle("Select Agent")) + return dialog +} + +// Legacy types for compatibility - these will be removed when agents.go is updated +type ToolsDialog interface{ layout.Modal } diff --git a/packages/tui/internal/tui/tui.go b/packages/tui/internal/tui/tui.go index f9a014ddbf..4a441c2991 100644 --- a/packages/tui/internal/tui/tui.go +++ b/packages/tui/internal/tui/tui.go @@ -414,6 +414,8 @@ func (a Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case app.SessionClearedMsg: a.app.Session = &opencode.Session{} a.app.Messages = []app.Message{} + // Clear home screen overrides to ensure clean defaults + a.app.ClearHomeScreenOverrides() case dialog.CompletionDialogCloseMsg: a.showCompletionDialog = false case opencode.EventListResponseEventInstallationUpdated: @@ -611,10 +613,33 @@ func (a Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { slog.Error("Failed to list messages", "error", err.Error()) return a, toast.NewErrorToast("Failed to open session") } + + // Load session-specific overrides (tools and subagents) + if err := a.app.LoadSessionOverrides(context.Background(), msg.ID); err != nil { + slog.Warn("Failed to load session overrides", "sessionID", msg.ID, "error", err) + // Continue anyway, don't fail the session switch + } + a.app.Session = msg a.app.Messages = messages return a, util.CmdHandler(app.SessionLoadedMsg{}) case app.SessionCreatedMsg: + // Copy any home screen overrides (stored under "" key) to the new session + a.app.TransferHomeScreenOverrides(msg.Session.ID) + + // Immediately persist transferred overrides to the server + currentAgent := a.app.Agent().Name + if toolOverrides := a.app.GetSessionToolOverrides(currentAgent); len(toolOverrides) > 0 { + go func() { + _ = a.app.SaveSessionToolOverrides(context.Background(), currentAgent, toolOverrides) + }() + } + if agentOverrides := a.app.GetSessionSubagentOverrides(currentAgent); len(agentOverrides) > 0 { + go func() { + _ = a.app.SaveSessionSubagentOverrides(context.Background(), currentAgent, agentOverrides) + }() + } + a.app.Session = msg.Session return a, util.CmdHandler(app.SessionLoadedMsg{}) case app.MessageRevertedMsg: @@ -634,6 +659,62 @@ func (a Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { updated, cmd := a.app.SwitchToAgent(msg.AgentName) a.app = updated cmds = append(cmds, cmd) + // Find the agent index + for i, agent := range a.app.Agents { + if agent.Name == msg.AgentName { + a.app.AgentIndex = i + break + } + } + a.app.State.Agent = msg.AgentName + + // Switch to the agent's preferred model if available + if model, ok := a.app.State.AgentModel[msg.AgentName]; ok { + for _, provider := range a.app.Providers { + if provider.ID == model.ProviderID { + a.app.Provider = &provider + for _, m := range provider.Models { + if m.ID == model.ModelID { + a.app.Model = &m + break + } + } + break + } + } + } + cmds = append(cmds, a.app.SaveState()) + case app.ToolsUpdatedMsg: + if a.app.HasActiveSession() { + // Update session-specific overrides (in-memory) + a.app.SetSessionToolOverrides(msg.Agent, msg.Overrides) + // Send to server for persistence across sessions + cmds = append(cmds, func() tea.Msg { + if err := a.app.SaveSessionToolOverrides(context.Background(), msg.Agent, msg.Overrides); err != nil { + return toast.NewErrorToast("Failed to save tool settings")() + } + return nil + }) + cmds = append(cmds, toast.NewSuccessToast("Tools updated")) + } else { + // No active session - just store in memory (no persistence) + a.app.SetSessionToolOverrides(msg.Agent, msg.Overrides) + cmds = append(cmds, toast.NewSuccessToast("Tools updated")) + } + case app.AgentsUpdatedMsg: + if a.app.HasActiveSession() { + a.app.SetSessionSubagentOverrides(a.app.Agent().Name, msg.Overrides) + cmds = append(cmds, func() tea.Msg { + if err := a.app.SaveSessionSubagentOverrides(context.Background(), a.app.Agent().Name, msg.Overrides); err != nil { + return toast.NewErrorToast("Failed to save subagent settings")() + } + return nil + }) + cmds = append(cmds, toast.NewSuccessToast("Subagents updated")) + } else { + a.app.SetSessionSubagentOverrides(a.app.Agent().Name, msg.Overrides) + cmds = append(cmds, toast.NewSuccessToast("Subagents updated")) + } case dialog.ThemeSelectedMsg: a.app.State.Theme = msg.ThemeName cmds = append(cmds, a.app.SaveState()) @@ -684,6 +765,9 @@ func (a Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case "/tui/open-models": modelDialog := dialog.NewModelDialog(a.app) a.modal = modelDialog + case "/tui/open-tools": + toolsDialog := dialog.NewToolsDialog(a.app) + a.modal = toolsDialog case "/tui/append-prompt": var body struct { Text string `json:"text"` @@ -1190,6 +1274,9 @@ func (a Model) executeCommand(command commands.Command) (tea.Model, tea.Cmd) { updated, cmd := a.app.CycleRecentModel() a.app = updated cmds = append(cmds, cmd) + case commands.ToolListCommand: + toolsDialog := dialog.NewToolsDialog(a.app) + a.modal = toolsDialog case commands.ThemeListCommand: themeDialog := dialog.NewThemeDialog() a.modal = themeDialog