Skip to content

Commit c5c81d9

Browse files
committed
Code accordtion consolidation, transition to ViewOutputBlocks
1 parent 090b21a commit c5c81d9

File tree

17 files changed

+599
-444
lines changed

17 files changed

+599
-444
lines changed

jest.config.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ module.exports = {
2828
"^vscode$": "<rootDir>/src/__mocks__/vscode.js",
2929
"@modelcontextprotocol/sdk$": "<rootDir>/src/__mocks__/@modelcontextprotocol/sdk/index.js",
3030
"@modelcontextprotocol/sdk/(.*)": "<rootDir>/src/__mocks__/@modelcontextprotocol/sdk/$1",
31+
"^execa$": "<rootDir>/src/__mocks__/execa.js", // Add mapping for execa mock
3132
"^delay$": "<rootDir>/src/__mocks__/delay.js",
3233
"^p-wait-for$": "<rootDir>/src/__mocks__/p-wait-for.js",
3334
"^serialize-error$": "<rootDir>/src/__mocks__/serialize-error.js",

src/__mocks__/execa.js

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
// Mock for the 'execa' package
2+
3+
// Mock ExecaError class for instanceof checks
4+
class ExecaError extends Error {
5+
constructor(message, options = {}) {
6+
super(message)
7+
this.name = "ExecaError"
8+
this.exitCode = options.exitCode ?? 1
9+
this.signal = options.signal ?? undefined
10+
this.stdout = options.stdout ?? ""
11+
this.stderr = options.stderr ?? ""
12+
this.all = options.all ?? ""
13+
this.failed = options.failed ?? true
14+
this.timedOut = options.timedOut ?? false
15+
this.isCanceled = options.isCanceled ?? false
16+
this.isKilled = options.isKilled ?? false
17+
// Add any other properties accessed in tests if needed
18+
}
19+
}
20+
21+
// Mock the main execa function (handling tagged template literal usage)
22+
const mockExeca = (_options) => {
23+
// Prefix unused parameter with _
24+
// The tagged template literal part is ignored in this simple mock
25+
// We just return an object simulating the subprocess
26+
const subprocess = (async function* () {
27+
// Yield some mock output lines
28+
yield "Mock execa output line 1"
29+
yield "Mock execa output line 2"
30+
// Simulate command completion (or potential error throwing if needed for tests)
31+
})()
32+
33+
// Add properties/methods expected on the subprocess object if needed by tests
34+
// For now, just making it async iterable is the main requirement from ExecaTerminalProcess.ts
35+
subprocess.stdout = { pipe: () => {} } // Mock minimal stream properties if needed
36+
subprocess.stderr = { pipe: () => {} }
37+
subprocess.all = { pipe: () => {} } // If combined output stream is used
38+
39+
// Mock the promise interface if needed (e.g., if .then() is called on the result)
40+
subprocess.then = (resolve, reject) => {
41+
// Simulate successful completion after iteration
42+
Promise.resolve().then(async () => {
43+
try {
44+
// eslint-disable-next-line no-empty,@typescript-eslint/no-unused-vars
45+
for await (const _ of subprocess) {
46+
} // Consume the generator
47+
resolve({ exitCode: 0, stdout: "Mock stdout", stderr: "Mock stderr" })
48+
} catch (error) {
49+
reject(error)
50+
}
51+
})
52+
}
53+
subprocess.catch = (reject) => {
54+
// Simulate successful completion by not calling reject
55+
// Modify this if tests require catching specific errors
56+
Promise.resolve().then(async () => {
57+
try {
58+
// eslint-disable-next-line no-empty,@typescript-eslint/no-unused-vars
59+
for await (const _ of subprocess) {
60+
} // Consume the generator
61+
} catch (error) {
62+
reject(error) // Pass through errors from the generator if any
63+
}
64+
})
65+
}
66+
subprocess.finally = (callback) => {
67+
Promise.resolve(subprocess).finally(callback)
68+
}
69+
70+
return subprocess
71+
}
72+
73+
module.exports = {
74+
execa: mockExeca,
75+
ExecaError: ExecaError,
76+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import * as vscode from "vscode"
2+
3+
interface VirtualContentData {
4+
content: string
5+
language?: string // Optional language for syntax highlighting
6+
}
7+
8+
// Simple in-memory storage for virtual document content
9+
const virtualContentStore = new Map<string, VirtualContentData>()
10+
11+
export class VirtualContentProvider implements vscode.TextDocumentContentProvider {
12+
// Optional: Add an event emitter if you need to signal updates for live changes
13+
// private _onDidChange = new vscode.EventEmitter<vscode.Uri>();
14+
// readonly onDidChange = this._onDidChange.event;
15+
16+
provideTextDocumentContent(uri: vscode.Uri): string {
17+
const contentPath = uri.path
18+
const storedData = virtualContentStore.get(contentPath)
19+
return storedData?.content || `// Error: Could not find content for URI: ${uri.toString()}`
20+
}
21+
22+
static addContent(scheme: string, content: string, language?: string): vscode.Uri {
23+
const id = Date.now().toString() // Simple unique ID
24+
let path
25+
26+
// Construct path, incorporating language if provided (primarily for tool outputs)
27+
if (language) {
28+
path = `fragment-${id}.${language.toLowerCase() || "txt"}`
29+
} else {
30+
// For API requests or content without a specific language
31+
path = `content-${id}.md` // Default to markdown for API requests
32+
}
33+
34+
virtualContentStore.set(path, { content, language })
35+
36+
// Optional: Clean up old entries after a while
37+
// The key for deletion should be the `path` used to store the content
38+
setTimeout(() => virtualContentStore.delete(path), 60 * 1000 * 5) // Clear after 5 minutes
39+
40+
return vscode.Uri.parse(`${scheme}:${path}`)
41+
}
42+
43+
// Helper to signal a change in a document's content, if needed for live updates
44+
// public signalChange(uri: vscode.Uri) {
45+
// this._onDidChange.fire(uri);
46+
// }
47+
}

src/core/webview/__tests__/ClineProvider.test.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -148,13 +148,14 @@ jest.mock("vscode", () => ({
148148
window: {
149149
showInformationMessage: jest.fn(),
150150
showErrorMessage: jest.fn(),
151+
createTextEditorDecorationType: jest.fn(), // Add mock for decoration type
151152
},
152153
workspace: {
153154
getConfiguration: jest.fn().mockReturnValue({
154155
get: jest.fn().mockReturnValue([]),
155156
update: jest.fn(),
156157
}),
157-
onDidChangeConfiguration: jest.fn().mockImplementation(() => ({
158+
onDidChangeConfiguration: jest.fn().mockImplementation((_callback) => ({
158159
dispose: jest.fn(),
159160
})),
160161
onDidSaveTextDocument: jest.fn(() => ({ dispose: jest.fn() })),
@@ -307,7 +308,9 @@ describe("ClineProvider", () => {
307308
callback()
308309
return { dispose: jest.fn() }
309310
}),
310-
onDidChangeVisibility: jest.fn().mockImplementation(() => ({ dispose: jest.fn() })),
311+
onDidChangeVisibility: jest.fn().mockImplementation((_callback) => {
312+
return { dispose: jest.fn() }
313+
}),
311314
} as unknown as vscode.WebviewView
312315

313316
provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext))
@@ -2037,6 +2040,7 @@ describe.skip("ContextProxy integration", () => {
20372040
let mockContext: vscode.ExtensionContext
20382041
let mockOutputChannel: vscode.OutputChannel
20392042
let mockContextProxy: any
2043+
let _mockGlobalStateUpdate: jest.Mock // Re-prefixed unused variable in skipped block
20402044

20412045
beforeEach(() => {
20422046
// Reset mocks
@@ -2058,6 +2062,8 @@ describe.skip("ContextProxy integration", () => {
20582062
mockOutputChannel = { appendLine: jest.fn() } as unknown as vscode.OutputChannel
20592063
mockContextProxy = new ContextProxy(mockContext)
20602064
provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", mockContextProxy)
2065+
2066+
_mockGlobalStateUpdate = mockContext.globalState.update as jest.Mock // Prefix assignment as well
20612067
})
20622068

20632069
test("updateGlobalState uses contextProxy", async () => {

src/core/webview/webviewMessageHandler.ts

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ import { generateSystemPrompt } from "./generateSystemPrompt"
3939

4040
const ALLOWED_VSCODE_SETTINGS = new Set(["terminal.integrated.inheritEnv"])
4141

42+
import { VirtualContentProvider } from "../virtualContent/VirtualContentProvider"
43+
import { API_REQUEST_VIEW_URI_SCHEME, TOOL_OUTPUT_VIEW_URI_SCHEME } from "../../extension"
4244
export const webviewMessageHandler = async (provider: ClineProvider, message: WebviewMessage) => {
4345
// Utility functions provided for concise get/update of global state via contextProxy API.
4446
const getGlobalState = <K extends keyof GlobalState>(key: K) => provider.contextProxy.getValue(key)
@@ -492,9 +494,54 @@ export const webviewMessageHandler = async (provider: ClineProvider, message: We
492494
case "soundEnabled":
493495
const soundEnabled = message.bool ?? true
494496
await updateGlobalState("soundEnabled", soundEnabled)
495-
setSoundEnabled(soundEnabled) // Add this line to update the sound utility
497+
setSoundEnabled(soundEnabled)
496498
await provider.postStateToWebview()
497499
break
500+
case "showApiRequestDetails": {
501+
const details = message.details
502+
if (typeof details === "string") {
503+
try {
504+
// Use the content provider to get a custom URI
505+
const customUri = VirtualContentProvider.addContent(API_REQUEST_VIEW_URI_SCHEME, details)
506+
// Open the document using the custom URI
507+
await vscode.window.showTextDocument(customUri, {
508+
preview: true,
509+
viewColumn: vscode.ViewColumn.Active,
510+
})
511+
} catch (error) {
512+
vscode.window.showErrorMessage(`Failed to open API request details: ${error}`)
513+
provider.log(`Error opening API request details via content provider: ${error}`)
514+
}
515+
} else {
516+
vscode.window.showWarningMessage("Could not display API request details: Invalid format received.")
517+
provider.log(`Received invalid format for showApiRequestDetails: ${typeof details}`)
518+
}
519+
break
520+
}
521+
case "showToolOutput": {
522+
const content = message.content
523+
const language = message.language || "plaintext"
524+
if (typeof content === "string") {
525+
try {
526+
// Use the generic content provider
527+
const customUri = VirtualContentProvider.addContent(TOOL_OUTPUT_VIEW_URI_SCHEME, content, language)
528+
console.log(customUri)
529+
530+
// Open the document using the custom URI
531+
await vscode.window.showTextDocument(customUri, {
532+
preview: true,
533+
viewColumn: vscode.ViewColumn.Active,
534+
})
535+
} catch (error) {
536+
vscode.window.showErrorMessage(`Failed to open tool output: ${error}`)
537+
provider.log(`Error opening tool output via content provider: ${error}`)
538+
}
539+
} else {
540+
vscode.window.showWarningMessage("Could not display tool output: Invalid content received.")
541+
provider.log(`Received invalid content for showToolOutput: ${typeof content}`)
542+
}
543+
break // Correct break for showToolOutput case
544+
}
498545
case "soundVolume":
499546
const soundVolume = message.value ?? 0.5
500547
await updateGlobalState("soundVolume", soundVolume)

src/extension.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,11 @@ import {
3232
CodeActionProvider,
3333
} from "./activate"
3434
import { initializeI18n } from "./i18n"
35+
import { VirtualContentProvider } from "./core/virtualContent/VirtualContentProvider"
36+
37+
// Define the scheme for API request virtual documents
38+
export const API_REQUEST_VIEW_URI_SCHEME = "roo-api-request"
39+
export const TOOL_OUTPUT_VIEW_URI_SCHEME = "roo-fragment"
3540

3641
/**
3742
* Built using https://github.com/microsoft/vscode-webview-ui-toolkit
@@ -100,16 +105,27 @@ export async function activate(context: vscode.ExtensionContext) {
100105
*
101106
* https://code.visualstudio.com/api/extension-guides/virtual-documents
102107
*/
108+
103109
const diffContentProvider = new (class implements vscode.TextDocumentContentProvider {
104110
provideTextDocumentContent(uri: vscode.Uri): string {
111+
// Content is expected to be base64 encoded in the query parameter
105112
return Buffer.from(uri.query, "base64").toString("utf-8")
106113
}
107114
})()
108-
109115
context.subscriptions.push(
110116
vscode.workspace.registerTextDocumentContentProvider(DIFF_VIEW_URI_SCHEME, diffContentProvider),
111117
)
112118

119+
// Register the content provider for generic virtual documents
120+
const virtualContentProvider = new VirtualContentProvider()
121+
context.subscriptions.push(
122+
vscode.workspace.registerTextDocumentContentProvider(API_REQUEST_VIEW_URI_SCHEME, virtualContentProvider),
123+
)
124+
125+
context.subscriptions.push(
126+
vscode.workspace.registerTextDocumentContentProvider(TOOL_OUTPUT_VIEW_URI_SCHEME, virtualContentProvider),
127+
)
128+
113129
context.subscriptions.push(vscode.window.registerUriHandler({ handleUri }))
114130

115131
// Register code actions provider.

src/i18n/locales/en/common.json

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,5 +89,14 @@
8989
"input": {
9090
"task_prompt": "What should Roo do?",
9191
"task_placeholder": "Type your task here"
92+
},
93+
"chat": {
94+
"shellIntegration": {
95+
"troubleshootingGuide": "troubleshooting guide"
96+
},
97+
"copyAsMarkdown": "Copy as markdown",
98+
"response": "Response",
99+
"arguments": "Arguments",
100+
"filePathFormat": " ({{filePath}})"
92101
}
93102
}

src/shared/WebviewMessage.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,12 @@ export interface WebviewMessage {
130130
| "searchFiles"
131131
| "toggleApiConfigPin"
132132
| "setHistoryPreviewCollapsed"
133+
| "showApiRequestDetails"
134+
| "showToolOutput"
133135
text?: string
136+
details?: string
137+
content?: string
138+
language?: string
134139
disabled?: boolean
135140
askResponse?: ClineAskResponse
136141
apiConfiguration?: ProviderSettings
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import React from "react"
2+
import { useTranslation } from "react-i18next"
3+
import { safeJsonParse } from "@roo/shared/safeJsonParse"
4+
import { vscode } from "@src/utils/vscode"
5+
import { ClineApiReqInfo, ClineMessage } from "@roo/shared/ExtensionMessage"
6+
7+
interface ApiRequestDetailsBlockProps {
8+
message: ClineMessage
9+
icon: React.ReactNode
10+
cost: number | undefined
11+
apiRequestFailedMessage: string | undefined
12+
apiReqStreamingFailedMessage: string | undefined
13+
}
14+
15+
const ApiRequestDetailsBlock: React.FC<ApiRequestDetailsBlockProps> = ({
16+
message,
17+
icon,
18+
cost,
19+
apiRequestFailedMessage,
20+
apiReqStreamingFailedMessage,
21+
}) => {
22+
const { t } = useTranslation()
23+
24+
return (
25+
<div className="min-h-7 rounded py-2 px-3 items-center cursor-pointer select-none border bg-vscode-editor-background border-green-400/50 ">
26+
<div
27+
className="flex gap-4"
28+
onClick={() => {
29+
const apiInfo = safeJsonParse<ClineApiReqInfo>(message.text)
30+
if (apiInfo?.request) {
31+
vscode.postMessage({
32+
type: "showApiRequestDetails",
33+
details: apiInfo.request,
34+
})
35+
}
36+
}}
37+
title={t("chat:apiRequest.viewDetailsTooltip") ?? "View API Request Details"}>
38+
{/* Use a generic icon or the original one if no error */}
39+
{!apiRequestFailedMessage && !apiReqStreamingFailedMessage ? (
40+
icon // Use original icon (spinner, check, error)
41+
) : (
42+
<span className="codicon codicon-code text-vscode-descriptionForeground mr-2.5"></span>
43+
)}
44+
<span className="whitespace-nowrap overflow-hidden text-ellipsis mr-2">
45+
{t("chat:apiRequest.title") ?? " API Request"}
46+
{/* Optionally show cost if available and not failed/cancelled */}
47+
{cost !== null &&
48+
cost !== undefined &&
49+
cost > 0 &&
50+
!apiRequestFailedMessage &&
51+
!apiReqStreamingFailedMessage && (
52+
<span className="text-xs opacity-70 ml-2">${Number(cost || 0)?.toFixed(2)}</span>
53+
)}
54+
</span>
55+
<div className="flex-grow"></div>
56+
<span className={`codicon codicon-link-external`}></span>
57+
</div>
58+
</div>
59+
)
60+
}
61+
62+
export default ApiRequestDetailsBlock

0 commit comments

Comments
 (0)