import * as vscode from 'vscode'; import * as path from 'path'; import { SidebarChatProvider } from '../sidebarProvider'; import { getActiveBrainProfile, logInfo } from '../utils'; import { pickConfigTarget } from '../lib/paths'; /** * Handles chat-domain messages: prompts, model selection, sessions, streaming control, * generic webview transport (export, settings, addMessage), action approvals, and the * cross-cutting `ready` bootstrap. * * Returns true when the message was handled by this domain, false otherwise — the * caller chains domain handlers until one accepts the message. */ export async function handleChatMessage(provider: SidebarChatProvider, data: any): Promise { switch (data.type) { case 'prompt': case 'promptWithFile': provider._lmStudio?.activity.bump(); await provider._context.globalState.update(SidebarChatProvider.blankChatStateKey, false); // ── 1인 기업 모드 우선 분기 ── // 회사 모드일 땐 들어온 메시지를 intent classifier에 한 번 통과시켜 // (a) 잡담/질문/짧은 응답 → 일반 채팅 경로, (b) 후속 대화 → 일반 채팅 // 경로 + followup 라벨, (c) 신규 업무 → 풀 파이프라인 dispatch. // classifier가 비활성화돼 있거나 호출 실패 시엔 안전하게 new_task로 // 폴백 — 즉 사용자가 의도한 작업 요청을 놓치는 일은 절대 없다. if (provider.isCompanyModeEnabled() && typeof data.value === 'string' && data.value.trim()) { let userPrompt = data.value.trim(); const { getConfig } = await import('../config'); const cfg = getConfig(); const { readCompanyState, resolveActivePipeline } = await import('../features/company'); const state = readCompanyState(provider._context); // ── alignment 답변 라우팅 ── // 사용자가 이전 메시지에서 alignment 카드를 받아 답변하는 중이면 // 이 메시지를 분류기/dispatch가 아니라 alignment 답변 핸들러로 // 보낸다. 답변이 처리되면서 자동으로 다음 라운드 또는 pipeline // 으로 진행됨. if (provider.isAlignmentPending()) { await provider._handleAlignmentAnswer(userPrompt); return true; } // ── 사용자 키워드 override ── // 입력 맨 앞에 `[파이프라인:id]` 또는 `[pipeline:id]`가 있으면 // 분류기 무관하게 그 파이프라인 강제 + 그 키워드는 prompt에서 // 제거 후 dispatcher에 전달. id가 유효하지 않으면 무시(분류기 정상 경로). let keywordOverrideId: string | undefined; const keywordMatch = userPrompt.match(/^\s*\[(?:파이프라인|pipeline)\s*:\s*([a-z0-9_-]+)\s*\]\s*/i); if (keywordMatch) { const id = keywordMatch[1].toLowerCase(); if (state.pipelines?.[id]) { keywordOverrideId = id; userPrompt = userPrompt.slice(keywordMatch[0].length).trim() || userPrompt; } } // ── alignment bypass 키워드 ── // 입력 맨 앞 `[건너뛰기]` 또는 `[skip]` → alignment 단계 1회 우회. // 사용자가 "지금은 빨리 가자"라고 명시한 경우에만 사용. prompt에서 // 키워드 제거. let alignmentBypass = false; const bypassMatch = userPrompt.match(/^\s*\[(?:건너뛰기|skip)\]\s*/i); if (bypassMatch) { alignmentBypass = true; userPrompt = userPrompt.slice(bypassMatch[0].length).trim() || userPrompt; } if (cfg.companyDisableIntentClassifier) { // 분류기 우회 모드 — 분류 단계는 건너뛰지만 alignment는 별도로 // 작동(사용자가 alignment off로 설정하지 않은 한). 분류기 끄는 // 이유는 보통 "잡담도 다 pipeline으로"인데 그럴수록 alignment // 효과가 큼. try { provider.pixelOfficeOnIntentClassified('new_task', userPrompt); } catch { /* noop */ } if (cfg.companyIntentAlignmentMode === 'off' || alignmentBypass) { await provider._runCompanyTurn(userPrompt, undefined, keywordOverrideId); } else { await provider._runIntentAlignment({ userPrompt, pipelineIdOverride: keywordOverrideId, mode: cfg.companyIntentAlignmentMode === 'strict' ? 'strict' : 'smart', roundsLimit: cfg.companyIntentAlignmentMaxRounds, roundsAsked: 0, }); } return true; } const { classifyChatIntent } = await import('../features/company'); const { AIService } = await import('../core/services'); const last = provider.getLastCompanyTurnSummary(); const activePipeline = resolveActivePipeline(state); // 사용 가능한 모든 파이프라인을 분류기 후보로 전달 — 단, 활성화돼 // 있어야 추천 의미가 있는 게 아니라 *정의돼 있기만 하면* 후보. 사용자가 // 평소엔 짧은 걸 활성화해 두고 가끔 풀 사이클 의도가 명확한 발화를 // 했을 때 분류기가 그쪽을 추천할 수 있게. const allPipelines = Object.values(state.pipelines ?? {}); const verdict = await classifyChatIntent( new AIService(), userPrompt, { previousBrief: last?.brief, previousReportTail: last?.reportTail, previousTurnAt: last?.finishedAt, activePipelineName: activePipeline?.name, availablePipelines: allPipelines.length > 0 ? allPipelines.map((p) => ({ id: p.id, name: p.name, stageCount: p.stages.length, })) : undefined, }, { model: cfg.companyIntentClassifierModel || cfg.defaultModel }, ); // Pixel Office: 분류 결과를 UI layer로만 흘림. 아래 분기 자체엔 영향 없음. try { provider.pixelOfficeOnIntentClassified(verdict.intent, userPrompt); } catch { /* noop */ } if (verdict.intent === 'new_task') { // 우선순위: (1) 사용자 키워드 (2) autoSelect가 켜져 있고 분류기 추천 있음 (3) 사용자 활성 파이프라인. let effectiveOverride = keywordOverrideId; if (!effectiveOverride && cfg.companyAutoSelectPipeline && verdict.suggestedPipelineId) { effectiveOverride = verdict.suggestedPipelineId; } // 분류기가 추천을 냈지만 autoSelect가 꺼져 있을 땐 라벨로만 안내. if (verdict.suggestedPipelineId && !effectiveOverride && !cfg.companyAutoSelectPipeline) { const tip = state.pipelines?.[verdict.suggestedPipelineId]; if (tip) { provider._view?.webview.postMessage({ type: 'companyIntentDecision', value: { intent: 'new_task', reason: `🧭 추천 파이프라인: "${tip.name}" (자동 적용은 설정 토글)`, label: '🛠️ 신규 업무', }, }); } } else if (effectiveOverride && effectiveOverride !== state.activePipelineId) { const used = state.pipelines?.[effectiveOverride]; if (used) { provider._view?.webview.postMessage({ type: 'companyIntentDecision', value: { intent: 'new_task', reason: keywordOverrideId ? `🔧 키워드 override → "${used.name}"` : `🧭 CEO 자동 선택 → "${used.name}"`, label: '🛠️ 신규 업무', }, }); } } // ── Intent Alignment 진입 ── // off 모드이거나 bypass 키워드가 있으면 alignment 우회하고 // legacy 동작 (즉시 dispatch). 그 외엔 분석기 1라운드 돌려 // confidence에 따라 자동 진행 또는 카드 표시. if (cfg.companyIntentAlignmentMode === 'off' || alignmentBypass) { await provider._runCompanyTurn(userPrompt, undefined, effectiveOverride); } else { await provider._runIntentAlignment({ userPrompt, pipelineIdOverride: effectiveOverride, mode: cfg.companyIntentAlignmentMode === 'strict' ? 'strict' : 'smart', roundsLimit: cfg.companyIntentAlignmentMaxRounds, roundsAsked: 0, }); } } else { await provider._handleCompanyCasual(userPrompt, verdict.intent, verdict.reason, data); } return true; } await provider._handlePrompt(data); await provider._autoWriteChronicleAfterPrompt(); await provider._saveCurrentSession(); return true; case 'activity': provider._lmStudio?.activity.bump(); return true; case 'ready': await provider._sendBrainStatus(); await provider._sendBrainProfiles(); await provider._sendSessionList(); await provider._sendModels(); await provider._sendChronicleProjects(); await provider._restoreActiveSessionIntoView(); await provider._sendReadyStatus(); // Restore the Project Architecture chip + watcher if the active project // was already running in architecture mode in a previous VS Code session. await provider._sendArchitectureStatus(); // Restore the Company chip from globalState so the user sees the same // mode they had on at last shutdown. await provider._sendCompanyStatus(); // Pixel Office — 첫 로드 시 빈 idle 상태라도 한 번 push해서 webview가 // 영역 자체를 그릴 수 있게. provider.pixelOfficeResend(); return true; case 'getReadyStatus': await provider._sendReadyStatus(); return true; case 'createLessonFromConversation': await vscode.commands.executeCommand('g1nation.lesson.fromConversation'); return true; case 'manageLessons': await vscode.commands.executeCommand('g1nation.lesson.manage'); return true; case 'getModels': await provider._sendModels(); return true; case 'getSessions': await provider._sendSessionList(); return true; case 'newChat': provider._currentSessionId = null; provider._currentSessionBrainId = getActiveBrainProfile().id; provider._agent.resetConversation(); await provider._context.globalState.update(SidebarChatProvider.activeSessionStateKey, null); await provider._context.globalState.update(SidebarChatProvider.lastVisibleChatStateKey, null); await provider._context.globalState.update(SidebarChatProvider.blankChatStateKey, true); // 직전 회사 turn 컨텍스트 캐시 비우기 — 새 세션은 followup 기준점이 없다. provider.clearLastCompanyTurnSummary(); // 진행 중이던 alignment도 새 세션과 함께 폐기. provider.cancelPendingAlignment(); provider.clearChat(); await provider._sendBrainStatus(); return true; case 'stopGeneration': // 1인 기업 모드는 AgentExecutor를 거치지 않으므로 별도 abort 경로. // 두 경로 모두 신호를 보내 두면 중간에 모드 전환되어도 안전. provider.abortCompanyTurn(); // 진행 중인 Intent Alignment도 같이 정리 — 사용자가 Stop 누르면 // 의도상 모든 대기 상태 해제. provider.cancelPendingAlignment(); provider._agent.stop(); return true; case 'loadSession': // 세션 전환 시 직전 회사 turn 캐시 무효화 — 로드된 세션의 직전 회사 // 작업은 별도 파일에서 복원되어야 하지 메모리 캐시에 남은 다른 // 세션의 보고서가 새 세션 첫 메시지의 followup 판정을 오염시키면 안 됨. provider.clearLastCompanyTurnSummary(); provider.cancelPendingAlignment(); await provider._loadSession(data.id); return true; case 'deleteSession': await provider._deleteSession(data.id); return true; case 'openSettings': // Route the sidebar gear button to Astra's own settings webview. // Falls back to VS Code Settings if the view hasn't registered yet // (e.g. during the very first activation pass) and surfaces any // unexpected error so the user isn't stuck with a silent button. try { await vscode.commands.executeCommand('g1nation.settings.focus'); } catch (e: any) { logInfo('openSettings: settings.focus failed, falling back to VS Code Settings.', { error: e?.message ?? String(e) }); try { await vscode.commands.executeCommand('workbench.action.openSettings', 'g1nation'); } catch (e2: any) { vscode.window.showErrorMessage(`Astra Settings 열기 실패: ${e2?.message ?? e2}`); } } return true; case 'addMessage': provider._view?.webview.postMessage({ type: 'addMessage', role: data.role, value: data.value, rationale: data.rationale }); return true; case 'refreshModels': await provider._sendModels(true); return true; case 'model': { // Write to whichever scope already holds the value so a stale // Workspace override doesn't shadow our Global update — that was // the "sidebar shows e2b but Settings shows e4b" desync. const { target } = pickConfigTarget('g1nation', 'defaultModel'); await vscode.workspace.getConfiguration('g1nation').update('defaultModel', data.value, target); logInfo(`Default model updated to: ${data.value}`, { target }); provider._lmStudio?.lifecycle.onModelSelected(data.value); return true; } case 'getKnowledgeMix': { // Ship the current global Knowledge Mix to the webview so the slider can // initialize. Per-agent overrides ride along with the agent map data. const cfg = vscode.workspace.getConfiguration('g1nation'); const w = cfg.get('knowledgeMix.secondBrainWeight', 50); const clamped = Math.max(0, Math.min(100, Math.round(typeof w === 'number' ? w : 50))); provider._view?.webview.postMessage({ type: 'knowledgeMix', value: { weight: clamped, source: 'global' }, }); return true; } case 'setKnowledgeMix': { const raw = typeof data.value === 'number' ? data.value : NaN; if (!Number.isFinite(raw)) return true; const clamped = Math.max(0, Math.min(100, Math.round(raw))); // Use whichever scope already holds the value to avoid the same "Workspace // override shadows Global update" desync that the `model` case guards against. const { target } = pickConfigTarget('g1nation', 'knowledgeMix.secondBrainWeight'); await vscode.workspace.getConfiguration('g1nation').update('knowledgeMix.secondBrainWeight', clamped, target); logInfo(`Knowledge Mix (global) updated to: ${clamped}`, { target }); return true; } // ── Project Architecture (Feature 2) ────────────────────────────────── case 'getArchitectureStatus': await provider._sendArchitectureStatus(); return true; case 'openArchitectureDoc': await provider._openArchitectureDoc(); return true; case 'refreshArchitecture': await provider._refreshArchitecture(); return true; case 'detachArchitecture': await provider._detachArchitecture(); return true; case 'attachArchitecture': // Re-enable architecture context for the current workspace — // user clicked the inactive chip's [Attach] button. await provider._attachArchitecture(); return true; case 'activateArchitectureFromText': { // Optional explicit-toggle path: webview can pass arbitrary text // (e.g. the current input draft) for one-shot intent detection. if (typeof data.text === 'string') { await provider._tryActivateArchitectureFromText(data.text); } return true; } // ── 1인 기업 모드 메시지 라우팅 ──────────────────────────────────── case 'getCompanyStatus': await provider._sendCompanyStatus(); return true; case 'getCompanyAgents': await provider._sendCompanyAgents(); return true; case 'getCompanyResumable': await provider._sendCompanyResumable(); return true; case 'resumeCompanyTurn': { // 사용자가 "이어서 진행" 칩을 눌렀을 때. timestamp만 받아서 디스크의 // _resume.json을 읽고 그 다음 stage부터 dispatch가 이어진다. const ts = typeof data.timestamp === 'string' ? data.timestamp : ''; if (!ts) return true; // userPrompt 인자는 resume 경로에서 무시되지만(plan은 디스크에서 복원) // 시그니처 일관성을 위해 dummy 값을 전달. void provider._runCompanyTurn('', ts); return true; } case 'discardResumableSession': { // 사용자가 명시적으로 재개 항목을 버리고 싶을 때 — resume 파일을 'failed'로 // 마킹해서 listResumable에서 자동 제외. markResumeStatus가 안전한 idempotent // 작업이라 별도 검증 불필요. const ts = typeof data.timestamp === 'string' ? data.timestamp : ''; if (!ts) return true; try { const { resolveSessionDir } = await import('../features/company'); const { markResumeStatus } = await import('../features/company/resumeStore'); markResumeStatus(resolveSessionDir(provider._context, ts), 'failed', 'discarded-by-user'); } catch { /* 무시 — 다음 푸시에서 자연 복구 */ } await provider._sendCompanyResumable(); return true; } case 'setCompanyEnabled': { const { setCompanyEnabled } = await import('../features/company'); await setCompanyEnabled(provider._context, !!data.value); await provider._sendCompanyStatus(); return true; } case 'setCompanyName': { const { setCompanyName } = await import('../features/company'); await setCompanyName(provider._context, typeof data.value === 'string' ? data.value : ''); await provider._sendCompanyStatus(); return true; } case 'setCompanyActiveAgents': { const { setActiveAgents } = await import('../features/company'); const ids = Array.isArray(data.value) ? data.value.filter((v: unknown): v is string => typeof v === 'string') : []; await setActiveAgents(provider._context, ids); await provider._sendCompanyStatus(); await provider._sendCompanyAgents(); return true; } case 'setCompanyAgentModel': { const { setAgentModelOverride } = await import('../features/company'); const agentId = typeof data.agentId === 'string' ? data.agentId : ''; const model = typeof data.model === 'string' ? data.model : ''; if (agentId) { await setAgentModelOverride(provider._context, agentId, model); await provider._sendCompanyAgents(); } return true; } case 'setCompanyAgentDisplay': { // 이름/역할/이모지/색상 override. 페이로드는 setCompanyAgentPrompt와 // 동일한 패턴 — null이면 전체 reset, 각 필드 빈 문자열이면 그 필드만 reset. const { setAgentDisplayOverride } = await import('../features/company'); const agentId = typeof data.agentId === 'string' ? data.agentId : ''; if (!agentId) return true; const v = data.override; const override = v === null ? null : { name: typeof v?.name === 'string' ? v.name : undefined, role: typeof v?.role === 'string' ? v.role : undefined, emoji: typeof v?.emoji === 'string' ? v.emoji : undefined, color: typeof v?.color === 'string' ? v.color : undefined, }; const result = await setAgentDisplayOverride(provider._context, agentId, override); provider._view?.webview.postMessage({ type: 'setCompanyAgentDisplayResult', value: result.ok ? { ok: true, agentId } : { ok: false, reason: result.reason }, }); if (result.ok) await provider._sendCompanyAgents(); return true; } case 'setCompanyAgentRoleCategory': { // Override an agent's 직군. Empty / null payload value reverts to // the def's own roleCategory. CEO is rejected by the backend. const { setAgentRoleCategory } = await import('../features/company'); const agentId = typeof data.agentId === 'string' ? data.agentId : ''; if (!agentId) return true; const cat = (typeof data.value === 'string' && data.value.trim()) ? data.value.trim() : null; const result = await setAgentRoleCategory(provider._context, agentId, cat as any); provider._view?.webview.postMessage({ type: 'setCompanyAgentRoleCategoryResult', value: result.ok ? { ok: true, agentId } : { ok: false, reason: result.reason }, }); if (result.ok) await provider._sendCompanyAgents(); return true; } case 'setCompanyAgentKnowledgeMix': { // Per-agent Knowledge Mix override. `null`/missing value falls // back to the global slider. The dispatcher reads this on the // *next* turn — no restart required. const { setAgentKnowledgeMix } = await import('../features/company'); const agentId = typeof data.agentId === 'string' ? data.agentId : ''; if (!agentId) return true; const raw = data.value; const weight = (raw === null || raw === undefined || !Number.isFinite(Number(raw))) ? null : Math.max(0, Math.min(100, Math.round(Number(raw)))); await setAgentKnowledgeMix(provider._context, agentId, weight); await provider._sendCompanyAgents(); return true; } case 'setCompanyAgentPrompt': { // Patch one agent's persona / specialty / tagline. Each field is // optional in the payload; passing an *empty string* explicitly // clears that field (back to the default from `agents.ts`). // Sending `null` for the whole override resets every field at once. const { setAgentPromptOverride } = await import('../features/company'); const agentId = typeof data.agentId === 'string' ? data.agentId : ''; if (!agentId) return true; const v = data.override; const override = v === null ? null : { persona: typeof v?.persona === 'string' ? v.persona : undefined, specialty: typeof v?.specialty === 'string' ? v.specialty : undefined, tagline: typeof v?.tagline === 'string' ? v.tagline : undefined, }; await setAgentPromptOverride(provider._context, agentId, override); await provider._sendCompanyAgents(); return true; } case 'addCompanyAgent': { // User-defined agent. Payload: { def: CompanyAgentDef }. Returns // an `addCompanyAgentResult` so the UI overlay can keep its form // open + show an error when validation fails (id collision etc.). const { addCustomAgent } = await import('../features/company'); const def = data.def; const result = await addCustomAgent(provider._context, def ?? {}); provider._view?.webview.postMessage({ type: 'addCompanyAgentResult', value: result.ok ? { ok: true, agentId: def?.id } : { ok: false, reason: result.reason }, }); if (result.ok) { await provider._sendCompanyStatus(); await provider._sendCompanyAgents(); } return true; } case 'deleteCompanyAgent': { // Delete any agent (built-in via hide, custom via outright removal). // Backend checks pipeline usage and refuses if any stage references it. const { removeCompanyAgent } = await import('../features/company'); const agentId = typeof data.agentId === 'string' ? data.agentId : ''; if (!agentId) return true; const result = await removeCompanyAgent(provider._context, agentId); provider._view?.webview.postMessage({ type: 'deleteCompanyAgentResult', value: result.ok ? { ok: true, agentId, kind: result.kind } : { ok: false, agentId, reason: result.reason }, }); if (result.ok) { await provider._sendCompanyStatus(); await provider._sendCompanyAgents(); } return true; } case 'restoreHiddenAgent': { // Bring a previously-hidden built-in back into the manage panel. const { restoreHiddenAgent } = await import('../features/company'); const agentId = typeof data.agentId === 'string' ? data.agentId : ''; if (!agentId) return true; const result = await restoreHiddenAgent(provider._context, agentId); provider._view?.webview.postMessage({ type: 'restoreHiddenAgentResult', value: result.ok ? { ok: true, agentId } : { ok: false, reason: result.reason }, }); if (result.ok) { await provider._sendCompanyStatus(); await provider._sendCompanyAgents(); } return true; } case 'getCompanyPipelines': await provider._sendCompanyPipelines(); return true; case 'upsertCompanyPipeline': { const { upsertPipeline } = await import('../features/company'); const result = await upsertPipeline(provider._context, data.def ?? {}); provider._view?.webview.postMessage({ type: 'upsertCompanyPipelineResult', value: result.ok ? { ok: true } : { ok: false, reason: result.reason }, }); if (result.ok) await provider._sendCompanyPipelines(); return true; } case 'deleteCompanyPipeline': { const { deletePipeline } = await import('../features/company'); const pid = typeof data.pipelineId === 'string' ? data.pipelineId : ''; if (!pid) return true; const result = await deletePipeline(provider._context, pid); provider._view?.webview.postMessage({ type: 'deleteCompanyPipelineResult', value: result.ok ? { ok: true, pipelineId: pid } : { ok: false, reason: result.reason }, }); if (result.ok) await provider._sendCompanyPipelines(); return true; } case 'getCompanyPipelineTemplate': { // Returns a template's stages so the editor can pre-fill the form. const { getPipelineTemplate } = await import('../features/company'); const tplId = typeof data.templateId === 'string' ? data.templateId : ''; const tpl = getPipelineTemplate(tplId); provider._view?.webview.postMessage({ type: 'companyPipelineTemplateContent', value: tpl ? { templateId: tpl.templateId, suggestedPipelineId: tpl.suggestedPipelineId, suggestedPipelineName: tpl.suggestedPipelineName, stages: tpl.stages, } : null, }); return true; } case 'getPixelOfficeState': // webview가 처음 로드되었거나 사용자가 토글 ON 했을 때 캐시된 // 현재 상태를 다시 받기 위한 요청. read-only. provider.pixelOfficeResend(); return true; case 'openPixelOfficePanel': // 사이드바 mini Pixel Office의 ⛶ 버튼 → editor area에 전체보기 panel 열기. provider.openPixelOfficePanel(); return true; case 'respondCompanyAlignment': { // alignment 카드 버튼: 'proceed' = 현 contract로 dispatch, 'cancel' = 폐기. const decision = typeof data.decision === 'string' ? data.decision : ''; if (decision === 'proceed') { await provider._proceedWithCurrentAlignment(); } else if (decision === 'cancel') { provider.cancelPendingAlignment(); } return true; } case 'respondCompanyApproval': { // Webview의 승인 카드 버튼 클릭 → dispatcher의 await 해제. // payload: { stageId, decision: 'approve' | 'revise' | 'abort', comment? } const stageId = typeof data.stageId === 'string' ? data.stageId : ''; const decision = typeof data.decision === 'string' ? data.decision : ''; if (!stageId || !['approve', 'revise', 'abort'].includes(decision)) return true; let payload: any; if (decision === 'approve') payload = { kind: 'approve' }; else if (decision === 'abort') payload = { kind: 'abort' }; else payload = { kind: 'revise', comment: typeof data.comment === 'string' ? data.comment : '' }; provider.resolveApprovalGate(stageId, payload); return true; } case 'setActiveCompanyPipeline': { const { setActivePipeline } = await import('../features/company'); const pid = typeof data.pipelineId === 'string' && data.pipelineId.trim() ? data.pipelineId.trim() : null; const result = await setActivePipeline(provider._context, pid); provider._view?.webview.postMessage({ type: 'setActiveCompanyPipelineResult', value: result.ok ? { ok: true, pipelineId: pid } : { ok: false, reason: result.reason }, }); if (result.ok) await provider._sendCompanyPipelines(); return true; } case 'proactiveTrigger': await provider._handleProactiveSuggestion(data.context); return true; case 'exportResponse': { const workspacePath = vscode.workspace.workspaceFolders?.[0].uri.fsPath || ''; const defaultPath = path.join(workspacePath, 'g1_response.md'); const uri = await vscode.window.showSaveDialog({ defaultUri: vscode.Uri.file(defaultPath), filters: { 'Markdown': ['md'] } }); if (uri) { await vscode.workspace.fs.writeFile(uri, Buffer.from(data.text, 'utf8')); vscode.window.showInformationMessage(`✅ Exported to ${path.basename(uri.fsPath)}`); } return true; } case 'approveAction': await provider._agent.approveTransaction(); return true; case 'rejectAction': await provider._agent.rejectTransaction(); return true; default: return false; } }