diff --git a/analyze_coverage.py b/analyze_coverage.py new file mode 100644 index 000000000..070f967a6 --- /dev/null +++ b/analyze_coverage.py @@ -0,0 +1,56 @@ + +import sys +import os + +def parse_coverage(filename): + if not os.path.exists(filename): + print(f"File {filename} not found") + return + + package_stmts = {} + package_covered = {} + + with open(filename, 'r') as f: + lines = f.readlines() + + current_mode = "" + for line in lines: + if line.startswith("mode:"): + current_mode = line.split()[1] + continue + + parts = line.strip().split(':') + if len(parts) != 2: + continue + + file_path = parts[0] + # Package is directory of file_path + package_name = os.path.dirname(file_path) + + metrics = parts[1].split() + if len(metrics) != 3: + continue + + # start_end = metrics[0] + num_stmts = int(metrics[1]) + count = int(metrics[2]) + + package_stmts[package_name] = package_stmts.get(package_name, 0) + num_stmts + if count > 0: + package_covered[package_name] = package_covered.get(package_name, 0) + num_stmts + + results = [] + for pkg, total in package_stmts.items(): + covered = package_covered.get(pkg, 0) + percent = (covered / total) * 100 if total > 0 else 0 + results.append((pkg, percent, covered, total)) + + # Sort by percentage (ascending) + results.sort(key=lambda x: x[1]) + + print("Package Coverage Report (Bottom 20):") + for pkg, pct, cov, tot in results[:20]: + print(f"{pct:6.2f}% ({cov}/{tot}) {pkg}") + +if __name__ == "__main__": + parse_coverage("coverage.out") diff --git a/cmd/pulse-agent/main.go b/cmd/pulse-agent/main.go index 9e076cc85..f41770faf 100644 --- a/cmd/pulse-agent/main.go +++ b/cmd/pulse-agent/main.go @@ -110,6 +110,10 @@ func run(ctx context.Context, args []string, getenv func(string) string) error { logger := zerolog.New(os.Stdout).Level(cfg.LogLevel).With().Timestamp().Logger() cfg.Logger = &logger + if cfg.InsecureSkipVerify { + logger.Warn().Msg("TLS verification disabled for agent connections (self-signed cert mode)") + } + // 2a. Handle Self-Test if cfg.SelfTest { logger.Info().Msg("Self-test passed: config loaded and logger initialized") diff --git a/docs/API.md b/docs/API.md index 5d1e3ed3f..55aff71c2 100644 --- a/docs/API.md +++ b/docs/API.md @@ -32,6 +32,7 @@ Some endpoints require admin privileges and/or scopes. Common scopes include: - `monitoring:read` - `settings:read` - `settings:write` +- `host-agent:config:read` Endpoints that require admin access are noted below. @@ -531,7 +532,7 @@ Removes a host agent from state. ### Agent Remote Config `GET /api/agents/host/{agent_id}/config` -Returns the server-side config payload for an agent (used by remote config and debugging). Requires `host-agent:report`. +Returns the server-side config payload for an agent (used by remote config and debugging). Requires `host-agent:config:read`. `PATCH /api/agents/host/{agent_id}/config` (admin, `host-agent:manage`) Updates server-side config for an agent (e.g., `commandsEnabled`). diff --git a/docs/CENTRALIZED_MANAGEMENT.md b/docs/CENTRALIZED_MANAGEMENT.md index 92ec51ca0..7da6c2953 100644 --- a/docs/CENTRALIZED_MANAGEMENT.md +++ b/docs/CENTRALIZED_MANAGEMENT.md @@ -94,6 +94,8 @@ GET /api/agents/host/{agent_id}/config Authorization: Bearer ``` +Requires `host-agent:config:read` (or admin tokens with management scopes). + ## Agent Behavior 1. On startup, the agent computes its Agent ID. diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 0ec4a163d..ec95687f7 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -161,6 +161,9 @@ Environment variables take precedence over `system.json`. |----------|-------------|---------| | `PULSE_PUBLIC_URL` | URL for UI links, notifications, and OIDC. For reverse proxies, keep this as the public URL and use `PULSE_AGENT_CONNECT_URL` for agent installs if you need a direct/internal address. | Auto-detected | | `PULSE_AGENT_CONNECT_URL` | Dedicated direct URL for agents (overrides `PULSE_PUBLIC_URL` for agent install commands). Alias: `PULSE_AGENT_URL`. | *(unset)* | +| `PULSE_AGENT_CONFIG_SIGNING_KEY` | Base64 Ed25519 private key used to sign remote agent config payloads. | *(unset)* | +| `PULSE_AGENT_CONFIG_PUBLIC_KEYS` | Comma-separated base64 Ed25519 public keys (raw 32-byte or PKIX-encoded) trusted by agents. | *(unset)* | +| `PULSE_AGENT_CONFIG_SIGNATURE_REQUIRED` | Require signed remote config payloads (set on Pulse and agents). | `false` | | `ALLOWED_ORIGINS` | CORS allowed origin (`*` or a single origin). Empty = same-origin only. | *(unset)* | | `DISCOVERY_ENABLED` | Auto-discover nodes | `false` | | `DISCOVERY_SUBNET` | CIDR or `auto` | `auto` | diff --git a/docs/UNIFIED_AGENT.md b/docs/UNIFIED_AGENT.md index 45fd6c3b6..e4e4720b3 100644 --- a/docs/UNIFIED_AGENT.md +++ b/docs/UNIFIED_AGENT.md @@ -207,6 +207,9 @@ Behavior: - Profile settings override local flags/env for supported keys. - Profile changes take effect on the next agent restart. - Command execution (`commandsEnabled`) is controlled per agent in **Settings → Agents → Unified Agents** and can change live. +- Remote config responses can be signed with `PULSE_AGENT_CONFIG_SIGNING_KEY` (base64 Ed25519 private key). +- To require signed payloads, set `PULSE_AGENT_CONFIG_SIGNATURE_REQUIRED=true` on Pulse and agents. +- If you use a custom signing key, set `PULSE_AGENT_CONFIG_PUBLIC_KEYS` on agents to trust the matching public key. See [Centralized Agent Management](CENTRALIZED_MANAGEMENT.md) for supported keys and profile setup. diff --git a/frontend-modern/src/api/agentProfiles.ts b/frontend-modern/src/api/agentProfiles.ts index 6a54738c9..cc666c6d1 100644 --- a/frontend-modern/src/api/agentProfiles.ts +++ b/frontend-modern/src/api/agentProfiles.ts @@ -39,6 +39,52 @@ export interface ProfileSuggestion { rationale: string[]; } +export interface ConfigKeyDefinition { + key: string; + type: string; + description: string; + defaultValue?: unknown; + required: boolean; + min?: number; + max?: number; + pattern?: string; + enum?: string[]; +} + +export interface ConfigValidationError { + key: string; + message: string; +} + +export interface ConfigValidationResult { + valid: boolean; + errors: ConfigValidationError[]; + warnings: ConfigValidationError[]; +} + +type ConfigKeyDefinitionResponse = { + Key: string; + Type: string; + Description: string; + Default: unknown; + Required: boolean; + Min?: number; + Max?: number; + Pattern?: string; + Enum?: string[]; +}; + +type ConfigValidationErrorResponse = { + Key: string; + Message: string; +}; + +type ConfigValidationResultResponse = { + Valid: boolean; + Errors?: ConfigValidationErrorResponse[]; + Warnings?: ConfigValidationErrorResponse[]; +}; + /** * API client for agent profiles (Pro feature). * Endpoints are gated behind license - returns 402 if not licensed. @@ -192,4 +238,44 @@ export class AgentProfilesAPI { return response.json(); } + + /** + * Fetch config schema definitions for agent profiles. + */ + static async getConfigSchema(): Promise { + const response = await apiFetchJSON(`${this.baseUrl}/schema`); + const defs = response || []; + return defs.map(def => ({ + key: def.Key, + type: def.Type, + description: def.Description, + defaultValue: def.Default, + required: def.Required, + min: def.Min, + max: def.Max, + pattern: def.Pattern, + enum: def.Enum, + })); + } + + /** + * Validate a config without saving. + */ + static async validateConfig(config: Record): Promise { + const response = await apiFetchJSON(`${this.baseUrl}/validate`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(config), + }); + + if (!response) { + return { valid: true, errors: [], warnings: [] }; + } + + return { + valid: response.Valid, + errors: (response.Errors || []).map(err => ({ key: err.Key, message: err.Message })), + warnings: (response.Warnings || []).map(err => ({ key: err.Key, message: err.Message })), + }; + } } diff --git a/frontend-modern/src/api/opencode.ts b/frontend-modern/src/api/opencode.ts index 6fdd7cb3e..f1ba01286 100644 --- a/frontend-modern/src/api/opencode.ts +++ b/frontend-modern/src/api/opencode.ts @@ -28,7 +28,7 @@ export interface ToolCall { } export interface StreamEvent { - type: 'content' | 'thinking' | 'tool_start' | 'tool_end' | 'tool' | 'done' | 'error'; + type: 'content' | 'thinking' | 'tool_start' | 'tool_end' | 'tool' | 'approval_needed' | 'question' | 'done' | 'error'; data?: any; } @@ -76,6 +76,29 @@ export class OpenCodeAPI { }); } + // Approve a pending command + static async approveCommand(approvalId: string): Promise<{ approved: boolean; message: string }> { + return apiFetchJSON(`${this.baseUrl}/approvals/${approvalId}/approve`, { + method: 'POST', + }) as Promise<{ approved: boolean; message: string }>; + } + + // Deny a pending command + static async denyCommand(approvalId: string, reason?: string): Promise<{ denied: boolean; message: string }> { + return apiFetchJSON(`${this.baseUrl}/approvals/${approvalId}/deny`, { + method: 'POST', + body: JSON.stringify({ reason: reason || 'User skipped' }), + }) as Promise<{ denied: boolean; message: string }>; + } + + // Answer a pending question from OpenCode + static async answerQuestion(questionId: string, answers: Array<{ id: string; value: string }>): Promise { + await apiFetch(`${this.baseUrl}/question/${questionId}/answer`, { + method: 'POST', + body: JSON.stringify({ answers }), + }); + } + // Stream chat - the main chat interface static async chat( prompt: string, diff --git a/frontend-modern/src/components/AI/Chat/ChatMessages.tsx b/frontend-modern/src/components/AI/Chat/ChatMessages.tsx index a78468d3c..5139b7d7c 100644 --- a/frontend-modern/src/components/AI/Chat/ChatMessages.tsx +++ b/frontend-modern/src/components/AI/Chat/ChatMessages.tsx @@ -1,11 +1,13 @@ import { Component, Show, For, createEffect } from 'solid-js'; import { MessageItem } from './MessageItem'; -import type { ChatMessage, PendingApproval } from './types'; +import type { ChatMessage, PendingApproval, PendingQuestion } from './types'; interface ChatMessagesProps { messages: ChatMessage[]; onApprove: (messageId: string, approval: PendingApproval) => void; onSkip: (messageId: string, toolId: string) => void; + onAnswerQuestion: (messageId: string, question: PendingQuestion, answers: Array<{ id: string; value: string }>) => void; + onSkipQuestion: (messageId: string, questionId: string) => void; emptyState?: { title: string; subtitle: string; @@ -98,6 +100,8 @@ export const ChatMessages: Component = (props) => { message={message} onApprove={(approval) => props.onApprove(message.id, approval)} onSkip={(toolId) => props.onSkip(message.id, toolId)} + onAnswerQuestion={(question, answers) => props.onAnswerQuestion(message.id, question, answers)} + onSkipQuestion={(questionId) => props.onSkipQuestion(message.id, questionId)} /> )} diff --git a/frontend-modern/src/components/AI/Chat/MessageItem.tsx b/frontend-modern/src/components/AI/Chat/MessageItem.tsx index 19c7c3899..010f02573 100644 --- a/frontend-modern/src/components/AI/Chat/MessageItem.tsx +++ b/frontend-modern/src/components/AI/Chat/MessageItem.tsx @@ -3,12 +3,15 @@ import { renderMarkdown } from '../aiChatUtils'; import { ThinkingBlock } from './ThinkingBlock'; import { ToolExecutionBlock, PendingToolBlock } from './ToolExecutionBlock'; import { ApprovalCard } from './ApprovalCard'; -import type { ChatMessage, PendingApproval, StreamDisplayEvent } from './types'; +import { QuestionCard } from './QuestionCard'; +import type { ChatMessage, PendingApproval, PendingQuestion, StreamDisplayEvent } from './types'; interface MessageItemProps { message: ChatMessage; onApprove: (approval: PendingApproval) => void; onSkip: (toolId: string) => void; + onAnswerQuestion: (question: PendingQuestion, answers: Array<{ id: string; value: string }>) => void; + onSkipQuestion: (questionId: string) => void; } /** @@ -24,7 +27,7 @@ export const MessageItem: Component = (props) => { props.message.streamEvents && props.message.streamEvents.length > 0; // Group stream events for cleaner rendering - // Combine consecutive content events, separate thinking and tools + // Combine consecutive content events, separate thinking, tools, and approvals const groupedEvents = createMemo(() => { const events = props.message.streamEvents || []; const grouped: StreamDisplayEvent[] = []; @@ -42,6 +45,24 @@ export const MessageItem: Component = (props) => { continue; } + // Pending tool events are kept separate + if (evt.type === 'pending_tool') { + grouped.push(evt); + continue; + } + + // Approval events are kept separate + if (evt.type === 'approval') { + grouped.push(evt); + continue; + } + + // Question events are kept separate + if (evt.type === 'question') { + grouped.push(evt); + continue; + } + // Content events can be merged with previous content if (evt.type === 'content' && evt.content) { const lastIdx = grouped.length - 1; @@ -119,12 +140,12 @@ export const MessageItem: Component = (props) => { {/* Content/text block */}
= (props) => { innerHTML={renderMarkdown(evt.content!)} /> + + {/* Approval card - inline in stream */} + +
+ props.onApprove(evt.approval!)} + onSkip={() => props.onSkip(evt.approval!.toolId)} + /> +
+
+ + {/* Question card - inline in stream */} + +
+ props.onAnswerQuestion(evt.question!, answers)} + onSkip={() => props.onSkipQuestion(evt.question!.questionId)} + /> +
+
)} @@ -154,21 +197,6 @@ export const MessageItem: Component = (props) => { /> - {/* Pending approvals */} - 0}> -
- - {(approval) => ( - props.onApprove(approval)} - onSkip={() => props.onSkip(approval.toolId)} - /> - )} - -
-
- {/* Streaming text indicator */} diff --git a/frontend-modern/src/components/AI/Chat/ToolExecutionBlock.tsx b/frontend-modern/src/components/AI/Chat/ToolExecutionBlock.tsx index 2418a9924..8c9d5365c 100644 --- a/frontend-modern/src/components/AI/Chat/ToolExecutionBlock.tsx +++ b/frontend-modern/src/components/AI/Chat/ToolExecutionBlock.tsx @@ -9,7 +9,7 @@ interface ToolExecutionBlockProps { * ToolExecutionBlock - Displays completed tool executions in a compact terminal-like style. */ export const ToolExecutionBlock: Component = (props) => { - const [showOutput, setShowOutput] = createSignal(false); + const [showOutput, setShowOutput] = createSignal(true); // Get display name for tool const toolLabel = createMemo(() => { diff --git a/frontend-modern/src/components/AI/Chat/hooks/useChat.ts b/frontend-modern/src/components/AI/Chat/hooks/useChat.ts index b8de41558..4f15fa01f 100644 --- a/frontend-modern/src/components/AI/Chat/hooks/useChat.ts +++ b/frontend-modern/src/components/AI/Chat/hooks/useChat.ts @@ -6,6 +6,7 @@ import type { ChatMessage, ToolExecution, StreamDisplayEvent, + PendingQuestion, } from '../types'; const generateId = () => Math.random().toString(36).substring(2, 9); @@ -173,6 +174,63 @@ export function useChat(options: UseChatOptions = {}) { }; } + case 'approval_needed': { + const data = event.data as { + command: string; + tool_id: string; + tool_name: string; + run_on_host: boolean; + target_host?: string; + approval_id?: string; + }; + + const approval = { + command: data.command, + toolId: data.tool_id, + toolName: data.tool_name, + runOnHost: data.run_on_host, + targetHost: data.target_host, + isExecuting: false, + approvalId: data.approval_id, + }; + + // Add to streamEvents for chronological display + const updated = addStreamEvent(msg, { type: 'approval', approval }); + + return { + ...updated, + pendingApprovals: [...(msg.pendingApprovals || []), approval], + }; + } + + case 'question': { + const data = event.data as { + question_id: string; + session_id: string; + questions: Array<{ + id: string; + type: 'text' | 'select'; + question: string; + options?: Array<{ label: string; value: string }>; + }>; + }; + + const pendingQuestion: PendingQuestion = { + questionId: data.question_id, + sessionId: data.session_id, + questions: data.questions, + isAnswering: false, + }; + + // Add to streamEvents for chronological display + const updated = addStreamEvent(msg, { type: 'question', question: pendingQuestion }); + + return { + ...updated, + pendingQuestions: [...(msg.pendingQuestions || []), pendingQuestion], + }; + } + case 'done': { return { ...msg, isStreaming: false, pendingTools: [] }; } @@ -198,6 +256,22 @@ export function useChat(options: UseChatOptions = {}) { const sendMessage = async (prompt: string) => { if (!prompt.trim() || isLoading()) return; + // Ensure we have a session for conversation continuity + // Without this, every message creates a new session and loses context + let currentSessionId = sessionId(); + if (!currentSessionId) { + try { + const session = await OpenCodeAPI.createSession(); + currentSessionId = session.id; + setSessionId(currentSessionId); + logger.debug('[useChat] Created new session', { sessionId: currentSessionId }); + } catch (error) { + logger.error('[useChat] Failed to create session:', error); + notificationStore.error('Failed to create chat session'); + return; + } + } + // Add user message const userMessage: ChatMessage = { id: generateId(), @@ -227,7 +301,7 @@ export function useChat(options: UseChatOptions = {}) { try { await OpenCodeAPI.chat( prompt, - sessionId() || undefined, + currentSessionId, model() || undefined, (event: StreamEvent) => { processEvent(assistantId, event); @@ -256,9 +330,10 @@ export function useChat(options: UseChatOptions = {}) { } }; - // Clear messages + // Clear messages and reset session (for starting fresh) const clearMessages = () => { setMessages([]); + setSessionId(''); // Clear session so next message creates a new one }; // Load session messages @@ -293,6 +368,84 @@ export function useChat(options: UseChatOptions = {}) { } }; + // Update pending approval state (e.g., to mark as executing or remove) + const updateApproval = (messageId: string, toolId: string, update: Partial<{ isExecuting: boolean; removed: boolean }>) => { + setMessages((prev) => + prev.map((msg) => { + if (msg.id !== messageId) return msg; + if (update.removed) { + // Remove from pendingApprovals + return { + ...msg, + pendingApprovals: (msg.pendingApprovals || []).filter((a) => a.toolId !== toolId), + }; + } + // Update the approval in place + return { + ...msg, + pendingApprovals: (msg.pendingApprovals || []).map((a) => + a.toolId === toolId ? { ...a, ...update } : a + ), + }; + }) + ); + }; + + // Add a tool call result to a message (after approval execution) + const addToolResult = (messageId: string, toolCall: { name: string; input: string; output: string; success: boolean }) => { + setMessages((prev) => + prev.map((msg) => { + if (msg.id !== messageId) return msg; + return { + ...msg, + toolCalls: [...(msg.toolCalls || []), toolCall], + streamEvents: [ + ...(msg.streamEvents || []), + { type: 'tool' as const, tool: toolCall }, + ], + }; + }) + ); + }; + + // Update pending question state (e.g., to mark as answering or remove) + const updateQuestion = (messageId: string, questionId: string, update: Partial<{ isAnswering: boolean; removed: boolean }>) => { + setMessages((prev) => + prev.map((msg) => { + if (msg.id !== messageId) return msg; + if (update.removed) { + // Remove from pendingQuestions + return { + ...msg, + pendingQuestions: (msg.pendingQuestions || []).filter((q) => q.questionId !== questionId), + }; + } + // Update the question in place + return { + ...msg, + pendingQuestions: (msg.pendingQuestions || []).map((q) => + q.questionId === questionId ? { ...q, ...update } : q + ), + }; + }) + ); + }; + + // Answer a pending question + const answerQuestion = async (messageId: string, questionId: string, answers: Array<{ id: string; value: string }>) => { + updateQuestion(messageId, questionId, { isAnswering: true }); + + try { + await OpenCodeAPI.answerQuestion(questionId, answers); + // Remove the question after successful answer + updateQuestion(messageId, questionId, { removed: true }); + } catch (error) { + logger.error('[useChat] Failed to answer question:', error); + notificationStore.error('Failed to answer question'); + updateQuestion(messageId, questionId, { isAnswering: false }); + } + }; + return { messages, isLoading, @@ -304,5 +457,9 @@ export function useChat(options: UseChatOptions = {}) { clearMessages, loadSession, newSession, + updateApproval, + addToolResult, + updateQuestion, + answerQuestion, }; } diff --git a/frontend-modern/src/components/AI/Chat/index.tsx b/frontend-modern/src/components/AI/Chat/index.tsx index a4adbcd3e..b39d6113d 100644 --- a/frontend-modern/src/components/AI/Chat/index.tsx +++ b/frontend-modern/src/components/AI/Chat/index.tsx @@ -1,10 +1,16 @@ -import { Component, Show, createSignal, onMount, For, createMemo } from 'solid-js'; +import { Component, Show, createSignal, onMount, For, createMemo, createEffect } from 'solid-js'; +import { AIAPI } from '@/api/ai'; import { OpenCodeAPI, type ChatSession } from '@/api/opencode'; import { notificationStore } from '@/stores/notifications'; import { logger } from '@/utils/logger'; import { useChat } from './hooks/useChat'; import { ChatMessages } from './ChatMessages'; -import type { PendingApproval } from './types'; +import { PROVIDER_DISPLAY_NAMES, getProviderFromModelId, groupModelsByProvider } from '../aiChatUtils'; +import type { PendingApproval, ModelInfo } from './types'; + +const MODEL_LEGACY_STORAGE_KEY = 'pulse:ai_chat_model'; +const MODEL_SESSION_STORAGE_KEY = 'pulse:ai_chat_models_by_session'; +const DEFAULT_SESSION_KEY = '__default__'; interface AIChatProps { onClose: () => void; @@ -22,9 +28,176 @@ export const AIChat: Component = (props) => { const [input, setInput] = createSignal(''); const [sessions, setSessions] = createSignal([]); const [showSessions, setShowSessions] = createSignal(false); + const [showModelSelector, setShowModelSelector] = createSignal(false); + const [models, setModels] = createSignal([]); + const [modelsLoading, setModelsLoading] = createSignal(false); + const [modelsError, setModelsError] = createSignal(''); + const [modelQuery, setModelQuery] = createSignal(''); + const [defaultModel, setDefaultModel] = createSignal(''); + const [chatOverrideModel, setChatOverrideModel] = createSignal(''); + + const loadModelSelections = (): Record => { + try { + const raw = localStorage.getItem(MODEL_SESSION_STORAGE_KEY); + const parsed = raw ? JSON.parse(raw) : {}; + const selections = typeof parsed === 'object' && parsed ? parsed as Record : {}; + const legacy = localStorage.getItem(MODEL_LEGACY_STORAGE_KEY); + if (legacy && !selections[DEFAULT_SESSION_KEY]) { + selections[DEFAULT_SESSION_KEY] = legacy; + } + if (legacy) { + localStorage.removeItem(MODEL_LEGACY_STORAGE_KEY); + } + return selections; + } catch (error) { + logger.warn('[AIChat] Failed to read stored models:', error); + return {}; + } + }; + + const persistModelSelections = (selections: Record) => { + try { + localStorage.setItem(MODEL_SESSION_STORAGE_KEY, JSON.stringify(selections)); + } catch (error) { + logger.warn('[AIChat] Failed to persist model selections:', error); + } + }; + + const initialModelSelections = loadModelSelections(); + const [modelSelections, setModelSelections] = createSignal>(initialModelSelections); + + const getStoredModel = (sessionId: string) => { + const key = sessionId || DEFAULT_SESSION_KEY; + return modelSelections()[key] || ''; + }; + + const updateStoredModel = (sessionId: string, modelId: string) => { + const key = sessionId || DEFAULT_SESSION_KEY; + setModelSelections((prev) => { + const next = { ...prev }; + if (modelId) { + next[key] = modelId; + } else { + delete next[key]; + } + persistModelSelections(next); + return next; + }); + }; // Chat hook - const chat = useChat(); + const chat = useChat({ model: initialModelSelections[DEFAULT_SESSION_KEY] || '' }); + + const defaultModelLabel = createMemo(() => { + const fallback = defaultModel().trim(); + if (!fallback) return ''; + const match = models().find((model) => model.id === fallback); + return match ? (match.name || match.id.split(':').pop() || match.id) : fallback; + }); + + const chatOverrideLabel = createMemo(() => { + const override = chatOverrideModel().trim(); + if (!override) return ''; + const match = models().find((model) => model.id === override); + return match ? (match.name || match.id.split(':').pop() || match.id) : override; + }); + + const selectedModelLabel = createMemo(() => { + const selected = chat.model().trim(); + if (!selected) { + const fallback = defaultModelLabel(); + return fallback ? `Default (${fallback})` : 'Default'; + } + const match = models().find((model) => model.id === selected); + if (match) return match.name || match.id.split(':').pop() || match.id; + return selected; + }); + + const filteredModels = createMemo(() => { + const query = modelQuery().trim().toLowerCase(); + if (!query) return models(); + return models().filter((model) => { + const provider = getProviderFromModelId(model.id); + const providerName = PROVIDER_DISPLAY_NAMES[provider] || provider; + const modelName = model.name || ''; + return ( + model.id.toLowerCase().includes(query) || + modelName.toLowerCase().includes(query) || + (model.description || '').toLowerCase().includes(query) || + provider.toLowerCase().includes(query) || + providerName.toLowerCase().includes(query) + ); + }); + }); + + const customModelCandidate = createMemo(() => modelQuery().trim()); + const showCustomModelOption = createMemo(() => { + const candidate = customModelCandidate(); + if (!candidate) return false; + return !models().some((model) => model.id === candidate); + }); + + const loadModels = async (notify = false) => { + if (notify) { + notificationStore.info('Refreshing models...', 2000); + } + setModelsLoading(true); + setModelsError(''); + try { + const result = await AIAPI.getModels(); + const nextModels = result.models || []; + setModels(nextModels); + if (result.error) { + setModelsError(result.error); + if (notify) { + notificationStore.warning(result.error, 6000); + } + } else if (notify) { + notificationStore.success(`Models refreshed (${nextModels.length})`, 2000); + } + } catch (error) { + logger.error('[AIChat] Failed to load models:', error); + setModels([]); + const message = error instanceof Error ? error.message : 'Failed to load models.'; + setModelsError(message); + notificationStore.error(message); + } finally { + setModelsLoading(false); + } + }; + + const loadSettings = async () => { + try { + const settings = await AIAPI.getSettings(); + const chatOverride = (settings.chat_model || '').trim(); + const fallback = chatOverride || (settings.model || '').trim(); + setDefaultModel(fallback); + setChatOverrideModel(chatOverride); + } catch (error) { + logger.error('[AIChat] Failed to load AI settings:', error); + } + }; + + const selectModel = (modelId: string) => { + chat.setModel(modelId); + updateStoredModel(chat.sessionId(), modelId); + setShowModelSelector(false); + setModelQuery(''); + }; + + createEffect(() => { + const sessionId = chat.sessionId(); + const storedModel = getStoredModel(sessionId); + if (storedModel) { + if (chat.model() !== storedModel) { + chat.setModel(storedModel); + } + return; + } + if (chat.model()) { + chat.setModel(''); + } + }); // Compute current status for display const currentStatus = createMemo(() => { @@ -60,6 +233,8 @@ export const AIChat: Component = (props) => { } const sessionList = await OpenCodeAPI.listSessions(); setSessions(sessionList); + await loadSettings(); + await loadModels(); } catch (error) { logger.error('[AIChat] Failed to initialize:', error); } @@ -100,6 +275,7 @@ export const AIChat: Component = (props) => { try { await OpenCodeAPI.deleteSession(sessionId); setSessions(prev => prev.filter(s => s.id !== sessionId)); + updateStoredModel(sessionId, ''); if (chat.sessionId() === sessionId) { chat.clearMessages(); } @@ -108,9 +284,84 @@ export const AIChat: Component = (props) => { } }; - // Empty state for approval (not used with OpenCode but keeping interface) - const handleApprove = (_messageId: string, _approval: PendingApproval) => { }; - const handleSkip = (_messageId: string, _toolId: string) => { }; + // Approval handlers + const handleApprove = async (messageId: string, approval: PendingApproval) => { + if (!approval.approvalId) { + notificationStore.error('No approval ID available'); + return; + } + + // Mark as executing + chat.updateApproval(messageId, approval.toolId, { isExecuting: true }); + + try { + const result = await OpenCodeAPI.approveCommand(approval.approvalId); + + // Remove from pending approvals + chat.updateApproval(messageId, approval.toolId, { removed: true }); + + // Add tool result if command was executed + if (result.approved) { + const execResult = (result as { result?: { stdout?: string; stderr?: string; exit_code?: number } }).result; + const output = execResult + ? `Exit code: ${execResult.exit_code}\n${execResult.stdout || ''}${execResult.stderr ? '\nStderr: ' + execResult.stderr : ''}` + : 'Command approved but no execution result available.'; + + chat.addToolResult(messageId, { + name: approval.toolName, + input: approval.command, + output: output.trim(), + success: execResult ? execResult.exit_code === 0 : false, + }); + } + } catch (error) { + logger.error('[AIChat] Approval failed:', error); + notificationStore.error('Failed to approve command'); + chat.updateApproval(messageId, approval.toolId, { isExecuting: false }); + } + }; + + const handleSkip = async (messageId: string, toolId: string) => { + // Find the approval to get the approvalId + const msg = chat.messages().find((m) => m.id === messageId); + const approval = msg?.pendingApprovals?.find((a) => a.toolId === toolId); + + if (!approval?.approvalId) { + // Just remove from UI if no approval ID + chat.updateApproval(messageId, toolId, { removed: true }); + return; + } + + try { + await OpenCodeAPI.denyCommand(approval.approvalId, 'User skipped'); + chat.updateApproval(messageId, toolId, { removed: true }); + } catch (error) { + logger.error('[AIChat] Skip/deny failed:', error); + // Still remove from UI even if API fails + chat.updateApproval(messageId, toolId, { removed: true }); + } + }; + + const toggleModelSelector = () => { + const next = !showModelSelector(); + setShowModelSelector(next); + if (next) { + setShowSessions(false); + setModelQuery(''); + if (models().length === 0 && !modelsLoading()) { + loadModels(); + } + } + }; + + const handleModelInputKeyDown = (e: KeyboardEvent) => { + if (e.key !== 'Enter') return; + e.preventDefault(); + const candidate = customModelCandidate(); + if (candidate) { + selectModel(candidate); + } + }; return (
= (props) => {
+ {/* Model selector */} +
+ + + +
+
+ setModelQuery(e.currentTarget.value)} + onKeyDown={handleModelInputKeyDown} + placeholder="Search or enter model ID" + class="flex-1 text-xs px-2 py-1.5 rounded-md border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-900 text-slate-700 dark:text-slate-200 focus:outline-none focus:ring-2 focus:ring-purple-400/50" + /> + +
+ + +
+ {modelsError()} +
+
+ +
+ + + + + + + + + + + +
+ No matching models. +
+
+ + + {([provider, providerModels]) => ( + <> +
+ {PROVIDER_DISPLAY_NAMES[provider] || provider} +
+ + {(model) => ( + + )} + + + )} +
+
+
+
+
+ {/* Session picker */}
+
+ +
+ 0} fallback={ +

No defaults to show.

+ }> + + {(def) => ( +
+ {def.key} + {formatDisplayValue(def.defaultValue)} +
+ )} +
+
+
+
+
+
+
+
+
Raw JSON
+
+
+                                                        {JSON.stringify(sugg().config, null, 2)}
+                                                    
+
+
@@ -193,6 +539,41 @@ export const SuggestProfileModal: Component = (props) + + 1}> +
+
+
Recent drafts
+ + {history().length - 1} older + +
+

+ Switch to a previous suggestion. +

+
+ item.id !== activeHistoryId())}> + {(item) => ( + + )} + +
+
+
)} @@ -223,18 +604,19 @@ export const SuggestProfileModal: Component = (props) >