Compare commits

...
Author SHA1 Message Date
Saoud Rizwan fec141af32 fix: restore accidentally removed promptContext fields
Commit ec68e7c2a accidentally removed enableParallelToolCalling and
terminalExecutionMode from promptContext when refactoring to add
editorTabs. These fields are still in SystemPromptContext interface
and actively used by system prompt templates.
2026-01-14 16:24:32 -08:00
Robin Newhouse a1dc73f932 feat: enable enhanced notebook interaction by default
Remove enhancedNotebookInteractionEnabled feature flag and enable notebook support globally.

Update tool handlers to process notebook cells automatically.

Update file extraction logic to support .ipynb files natively.

Clean up settings UI and state management.
2026-01-09 15:13:45 -08:00
Robin Newhouse ec68e7c2a8 feat: make replace_in_file prompt dynamic based on open files
Add editorTabs to SystemPromptContext to expose open/visible files.

Populate editorTabs in Task using HostProvider.

Conditionally include notebook-specific instructions in replace_in_file tool only when .ipynb files are open or visible.

Refactor replace_in_file prompt construction for better readability.
2026-01-09 15:13:45 -08:00
tekulam 52863261f4 Created proper diff views for vscode nd removed unnecessary logs 2026-01-09 15:13:45 -08:00
Robin Newhouse 6295cfb528 fix(mentions): reorder parameters in parseMentions signature
Reordered the parameters in the parseMentions function to move the default parameter to the end of the argument list. This change ensures consistency in the function signature and correctly aligns arguments at the call site in the Task class.

This update was done to fix failing tests.
2026-01-09 15:13:44 -08:00
Robin Newhouse cc66f32a3a fix: block notebook edits when enhanced interaction disabled
Prevent crashes when enhancedNotebookInteractionEnabled is false by blocking .ipynb file edits in WriteToFileToolHandler. Added validation to return an error message instructing the user to enable the setting, and set didRejectTool to stop the operation. Reading notebooks remains unaffected.
2026-01-09 15:13:44 -08:00
Robin Newhouse 022ccfc419 refactor: extract common notebook context logic for Jupyter commands
Extracted duplicated code into a helper function `getNotebookCommandContext` to handle active notebook checks, context retrieval, and cell JSON fetching. This reduces duplication in `JupyterGenerateCell` and `JupyterExplainCell` commands, improving code maintainability and readability. Minor import semicolon fix for consistency.
2026-01-09 15:13:44 -08:00
Robin Newhouse e518663fa6 feat: improve notebook handling for empty notebooks
- Add semicolon to import statement for consistency
- Prevent errors by checking cell count before accessing notebook cells
- Add fallback in getContextForCommand for active notebook editor when no text editor is available
- Ensures robustness when dealing with empty or cell-less notebooks in the VSCode extension
2026-01-09 15:13:44 -08:00
Robin Newhouse 02daa25b6f refactor: remove notebook_cell_json from proto, move notebook context to dedicated commands
The contributor's original implementation added notebook_cell_json to the
CommandContext proto, which was then populated in getContextForCommand() for
any notebook file when enhancedNotebookInteractionEnabled was set.

This couples notebook-specific functionality to the general command proto,
which feels heavy for a niche feature. Protos should stay clean and general.

Changes:
- Remove notebook_cell_json field from CommandContext proto
- Export findMatchingNotebookCell() from commandUtils.ts
- Update Jupyter commands (JupyterGenerateCell, JupyterExplainCell,
  JupyterImproveCell) in extension.ts to fetch cell JSON directly and
  bundle it into the notebookContext parameter
- Update command files to use only notebookContext parameter
- Remove notebook-specific handling from getContextForCommand()

Result: Notebook context only flows through dedicated Jupyter commands.
Regular commands (Add to Cline, Fix, etc.) work the same for all file types.
The proto stays clean and general-purpose.

Note: This removes the behavior where regular commands would get notebook
context when used on .ipynb files with enhancedNotebookInteractionEnabled.
That feature is now exclusive to the dedicated Jupyter menu commands.
2026-01-09 15:13:44 -08:00
Robin Newhouse f21b618531 feat(improve): unify prompt sending behavior for improveWithCline
Refactor improveWithCline to build a single prompt and handle sending uniformly: for notebooks, populate existing task if available and send immediately; otherwise, create new task. This unifies behavior across selected text and notebook contexts, removing dependency on sendAddToInputEvent and simplifying logic. Minor formatting tweaks in extension.ts for notebook context string.
2026-01-09 15:13:44 -08:00
Robin Newhouse 71aac3ea7c refactor: unify notebook output sanitization across two code paths
Previously, context menu commands (Add to Cline, Explain, etc.) wiped ALL
notebook outputs, while file mentions preserved text and only truncated images.
Additionally, when outputs weren't cleared, massive base64-encoded image data
was sent directly to the LLM, flooding context with garbage.

Changes:
- Create shared notebook-utils.ts with sanitization logic
- Update extract-text.ts to use shared utility
- Update commandUtils.ts to sanitize instead of clearing outputs

Both paths now truncate base64 image data with "[IMAGE DATA TRUNCATED]"
while preserving useful text outputs like print statements and errors.
2026-01-09 15:13:44 -08:00
Robin Newhouse e37e1a5714 fix: implement dynamic notebook instructions for replace_in_file
Leverages the new dynamic prompt infrastructure to conditionally inject Jupyter Notebook-specific instructions into the `replace_in_file` tool. This ensures that the model receives guidance on handling JSON structure in `.ipynb` files only when the `enhancedNotebookInteractionEnabled` setting is active, keeping the default prompt clean for other users.

- Added `enhancedNotebookInteractionEnabled` to global settings and system prompt context
- Updated `replace_in_file` tool to use a dynamic instruction function that appends notebook rules based on context
- Wired up state management to pass the setting value to the prompt builder
2026-01-09 15:13:44 -08:00
tekulam d6f96e565c Jupyter Notebook Enhancements 2026-01-09 15:13:43 -08:00
20 changed files with 684 additions and 78 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": minor
---
This pull request introduces comprehensive Jupyter Notebook support for Cline, enabling AI-assisted editing of `.ipynb` files with full cell-level context awareness. The feature allows users to seamlessly work with Jupyter notebooks using Cline's AI capabilities while preserving the notebook's JSON structure.
+45 -1
View File
@@ -201,6 +201,24 @@
"title": "Improve with Cline",
"category": "Cline"
},
{
"command": "cline.jupyterGenerateCell",
"title": "Generate Jupyter Cell with Cline",
"category": "Cline",
"icon": "$(sparkle)"
},
{
"command": "cline.jupyterExplainCell",
"title": "Explain Jupyter Cell with Cline",
"category": "Cline",
"icon": "$(question)"
},
{
"command": "cline.jupyterImproveCell",
"title": "Improve Jupyter Cell with Cline",
"category": "Cline",
"icon": "$(lightbulb)"
},
{
"command": "cline.openWalkthrough",
"title": "Open Walkthrough",
@@ -304,6 +322,25 @@
"when": "config.git.enabled && scmProvider == git && cline.isGeneratingCommit"
}
],
"notebook/toolbar": [
{
"command": "cline.jupyterGenerateCell",
"group": "navigation/add@1",
"when": "notebookType == 'jupyter-notebook' && config.cline.enhancedNotebookInteractionEnabled"
}
],
"notebook/cell/title": [
{
"command": "cline.jupyterExplainCell",
"group": "inline@1",
"when": "notebookType == 'jupyter-notebook' && config.cline.enhancedNotebookInteractionEnabled"
},
{
"command": "cline.jupyterImproveCell",
"group": "inline@2",
"when": "notebookType == 'jupyter-notebook' && config.cline.enhancedNotebookInteractionEnabled"
}
],
"commandPalette": [
{
"command": "cline.generateGitCommitMessage",
@@ -335,7 +372,14 @@
},
"configuration": {
"title": "Cline",
"properties": {}
"properties": {
"cline.enhancedNotebookInteractionEnabled": {
"type": "boolean",
"default": false,
"description": "Enable enhanced Jupyter Notebook (.ipynb) support with cell-level context awareness. When enabled, Cline can read and edit notebook cells while preserving the JSON structure.",
"order": 1
}
}
}
},
"scripts": {
+20 -4
View File
@@ -1,5 +1,6 @@
import { getFileMentionFromPath } from "@/core/mentions"
import { singleFileDiagnosticsToProblemsString } from "@/integrations/diagnostics"
import { Logger } from "@/services/logging/Logger"
import { telemetryService } from "@/services/telemetry"
import { CommandContext, Empty } from "@/shared/proto/index.cline"
import { Controller } from "../index"
@@ -7,8 +8,9 @@ import { sendAddToInputEvent } from "../ui/subscribeToAddToInput"
// 'Add to Cline' context menu in editor and code action
// Inserts the selected code into the chat.
export async function addToCline(controller: Controller, request: CommandContext): Promise<Empty> {
if (!request.selectedText) {
export async function addToCline(controller: Controller, request: CommandContext, notebookContext?: string): Promise<Empty> {
if (!request.selectedText?.trim() && !notebookContext) {
Logger.log("❌ No text selected and no notebook context - returning early")
return {}
}
@@ -16,14 +18,28 @@ export async function addToCline(controller: Controller, request: CommandContext
const fileMention = await getFileMentionFromPath(filePath)
let input = `${fileMention}\n\`\`\`\n${request.selectedText}\n\`\`\``
// Add notebook context if provided (includes cell JSON)
if (notebookContext) {
Logger.log("Adding notebook context for enhanced editing")
input += `\n${notebookContext}`
}
if (request.diagnostics.length) {
const problemsString = await singleFileDiagnosticsToProblemsString(filePath, request.diagnostics)
input += `\nProblems:\n${problemsString}`
}
await sendAddToInputEvent(input)
// Notebooks send immediately, regular adds just fill input
if (notebookContext && controller.task) {
await controller.task.handleWebviewAskResponse("messageResponse", input)
} else if (notebookContext) {
await controller.initTask(input)
} else {
await sendAddToInputEvent(input)
}
console.log("addToCline", request.selectedText, filePath, request.language)
console.log("addToCline", request.selectedText, filePath, request.language, notebookContext ? "with notebook context" : "")
telemetryService.captureButtonClick("codeAction_addToChat", controller.task?.ulid)
return {}
@@ -1,21 +1,35 @@
import { getFileMentionFromPath } from "@/core/mentions"
import { HostProvider } from "@/hosts/host-provider"
import { Logger } from "@/services/logging/Logger"
import { telemetryService } from "@/services/telemetry"
import { CommandContext, Empty } from "@/shared/proto/index.cline"
import { ShowMessageType } from "@/shared/proto/index.host"
import { Controller } from "../index"
export async function explainWithCline(controller: Controller, request: CommandContext): Promise<Empty> {
if (!request.selectedText || !request.selectedText.trim()) {
export async function explainWithCline(
controller: Controller,
request: CommandContext,
notebookContext?: string,
): Promise<Empty> {
if (!request.selectedText?.trim() && !notebookContext) {
HostProvider.window.showMessage({
type: ShowMessageType.INFORMATION,
message: "Please select some code to explain.",
})
return {}
}
const fileMention = await getFileMentionFromPath(request.filePath || "")
const prompt = `Explain the following code from ${fileMention}:
const filePath = request.filePath || ""
const fileMention = await getFileMentionFromPath(filePath)
let prompt = `Explain the following code from ${fileMention}:
\`\`\`${request.language}\n${request.selectedText}\n\`\`\``
// Add notebook context if provided (includes cell JSON)
if (notebookContext) {
Logger.log("Adding notebook context to explainWithCline task")
prompt += notebookContext
}
await controller.initTask(prompt)
telemetryService.captureButtonClick("codeAction_explainCode", controller.task?.ulid)
+4 -4
View File
@@ -9,10 +9,10 @@ export async function fixWithCline(controller: Controller, request: CommandConte
const fileMention = await getFileMentionFromPath(filePath)
const problemsString = await singleFileDiagnosticsToProblemsString(filePath, request.diagnostics)
await controller.initTask(
`Fix the following code in ${fileMention}
\`\`\`\n${request.selectedText}\n\`\`\`\n\nProblems:\n${problemsString}`,
)
const taskMessage = `Fix the following code in ${fileMention}
\`\`\`\n${request.selectedText}\n\`\`\`\n\nProblems:\n${problemsString}`
await controller.initTask(taskMessage)
console.log("fixWithCline", request.selectedText, request.filePath, request.language, problemsString)
telemetryService.captureButtonClick("codeAction_fixWithCline", controller.task?.ulid)
@@ -1,23 +1,44 @@
import { getFileMentionFromPath } from "@/core/mentions"
import { HostProvider } from "@/hosts/host-provider"
import { Logger } from "@/services/logging/Logger"
import { telemetryService } from "@/services/telemetry"
import { CommandContext, Empty } from "@/shared/proto/index.cline"
import { ShowMessageType } from "@/shared/proto/index.host"
import { Controller } from "../index"
export async function improveWithCline(controller: Controller, request: CommandContext): Promise<Empty> {
if (!request.selectedText || !request.selectedText.trim()) {
export async function improveWithCline(
controller: Controller,
request: CommandContext,
notebookContext?: string,
): Promise<Empty> {
if (!request.selectedText?.trim() && !notebookContext) {
Logger.log("❌ No text selected and no notebook context")
HostProvider.window.showMessage({
type: ShowMessageType.INFORMATION,
message: "Please select some code to improve.",
})
return {}
}
const fileMention = await getFileMentionFromPath(request.filePath || "")
const prompt = `Improve the following code from ${fileMention} (e.g., suggest refactorings, optimizations, or better practices):
\`\`\`${request.language}\n${request.selectedText}\n\`\`\``
const filePath = request.filePath || ""
const fileMention = await getFileMentionFromPath(filePath)
const hasSelectedText = request.selectedText?.trim()
await controller.initTask(prompt)
// Build prompt
let prompt = hasSelectedText
? `Improve the following code from ${fileMention} (e.g., suggest refactorings, optimizations, or better practices):\n\`\`\`${request.language}\n${request.selectedText}\n\`\`\``
: `Improve the current code in the current notebook cell from ${fileMention}. Suggest refactorings, optimizations, or better practices based on the cell context.`
if (notebookContext) {
Logger.log("Adding notebook context to improveWithCline task")
prompt += `\n${notebookContext}`
}
// Send: notebooks go to existing task if available, non-notebooks always create new task
if (notebookContext && controller.task) {
await controller.task.handleWebviewAskResponse("messageResponse", prompt)
} else {
await controller.initTask(prompt)
}
telemetryService.captureButtonClick("codeAction_improveCode", controller.task?.ulid)
@@ -1,5 +1,4 @@
import { buildApiHandler } from "@core/api"
import { Empty } from "@shared/proto/cline/common"
import {
PlanActMode,
@@ -1,27 +1,19 @@
import { ModelFamily } from "@/shared/prompts"
import { ClineDefaultTool } from "@/shared/tools"
import type { ClineToolSpec } from "../spec"
import { TASK_PROGRESS_PARAMETER } from "../types"
import { SystemPromptContext, TASK_PROGRESS_PARAMETER } from "../types"
const id = ClineDefaultTool.FILE_EDIT
const generic: ClineToolSpec = {
variant: ModelFamily.GENERIC,
id,
name: "replace_in_file",
description:
"Request to replace sections of content in an existing file using SEARCH/REPLACE blocks that define exact changes to specific parts of the file. This tool should be used when you need to make targeted changes to specific parts of a file.",
parameters: [
{
name: "path",
required: true,
instruction: `The path of the file to modify (relative to the current working directory {{CWD}})`,
usage: "File path here",
},
{
name: "diff",
required: true,
instruction: `One or more SEARCH/REPLACE blocks following this exact format:
const getOpenOrVisibleTabPaths = (context: SystemPromptContext) => {
return [...(context.editorTabs?.open ?? []), ...(context.editorTabs?.visible ?? [])]
}
const shouldIncludeNotebookInstructions = (context: SystemPromptContext) => {
return getOpenOrVisibleTabPaths(context).some((p) => p.endsWith(".ipynb"))
}
const BASE_DIFF_INSTRUCTIONS = `One or more SEARCH/REPLACE blocks following this exact format:
\`\`\`
------- SEARCH
[exact content to find]
@@ -44,7 +36,47 @@ const generic: ClineToolSpec = {
* Each line must be complete. Never truncate lines mid-way through as this can cause matching failures.
4. Special operations:
* To move code: Use two SEARCH/REPLACE blocks (one to delete from original + one to insert at new location)
* To delete code: Use empty REPLACE section`,
* To delete code: Use empty REPLACE section`
const NOTEBOOK_INSTRUCTIONS = `
5. For Jupyter Notebook (.ipynb) files:
* Match the exact JSON structure including quotes, commas, and \\n characters
* Each line in "source" array (except last) must end with "\\n"
* Each source line is a separate JSON string in the array
* Example SEARCH block for notebook:
------- SEARCH
"source": [
"x = 10\\n",
"print(x)"
]
=======
"source": [
"x = 100\\n",
"print(x)"
]
+++++++ REPLACE`
const diffInstruction = (context: SystemPromptContext) => {
return shouldIncludeNotebookInstructions(context) ? BASE_DIFF_INSTRUCTIONS + NOTEBOOK_INSTRUCTIONS : BASE_DIFF_INSTRUCTIONS
}
const generic: ClineToolSpec = {
variant: ModelFamily.GENERIC,
id,
name: "replace_in_file",
description:
"Request to replace sections of content in an existing file using SEARCH/REPLACE blocks that define exact changes to specific parts of the file. This tool should be used when you need to make targeted changes to specific parts of a file.",
parameters: [
{
name: "path",
required: true,
instruction: `The path of the file to modify (relative to the current working directory {{CWD}})`,
usage: "File path here",
},
{
name: "diff",
required: true,
instruction: diffInstruction,
usage: "Search and replace blocks here",
},
TASK_PROGRESS_PARAMETER,
@@ -66,30 +98,7 @@ const NATIVE_NEXT_GEN: ClineToolSpec = {
{
name: "diff",
required: true,
instruction: `One or more SEARCH/REPLACE blocks following this exact format:
\`\`\`
------- SEARCH
[exact content to find]
=======
[new content to replace with]
+++++++ REPLACE
\`\`\`
Critical rules:
1. SEARCH content must match the associated file section to find EXACTLY:
* Match character-for-character including whitespace, indentation, line endings
* Include all comments, docstrings, etc.
2. SEARCH/REPLACE blocks will ONLY replace the first match occurrence.
* Including multiple unique SEARCH/REPLACE blocks if you need to make multiple changes.
* Include *just* enough lines in each SEARCH section to uniquely match each set of lines that need to change.
* When using multiple SEARCH/REPLACE blocks, list them in the order they appear in the file.
3. Keep SEARCH/REPLACE blocks concise:
* Break large SEARCH/REPLACE blocks into a series of smaller blocks that each change a small portion of the file.
* Include just the changing lines, and a few surrounding lines if needed for uniqueness.
* Do not include long runs of unchanging lines in SEARCH/REPLACE blocks.
* Each line must be complete. Never truncate lines mid-way through as this can cause matching failures.
4. Special operations:
* To move code: Use two SEARCH/REPLACE blocks (one to delete from original + one to insert at new location)
* To delete code: Use empty REPLACE section`,
instruction: diffInstruction,
},
TASK_PROGRESS_PARAMETER,
],
+4
View File
@@ -95,6 +95,10 @@ export interface SystemPromptContext {
readonly providerInfo: ApiProviderInfo
readonly cwd?: string
readonly ide: string
readonly editorTabs?: {
readonly open?: readonly string[]
readonly visible?: readonly string[]
}
readonly supportsBrowserUse?: boolean
readonly mcpHub?: McpHub
readonly skills?: SkillMetadata[]
+11
View File
@@ -1778,10 +1778,21 @@ export class Task {
return toggles[skill.path] !== false
})
// Snapshot editor tabs so prompt tools can decide whether to include
// filetype-specific instructions (e.g. notebooks) without adding bespoke flags.
const openTabPaths = (await HostProvider.window.getOpenTabs({})).paths || []
const visibleTabPaths = (await HostProvider.window.getVisibleTabs({})).paths || []
const cap = 50
const editorTabs = {
open: openTabPaths.slice(0, cap),
visible: visibleTabPaths.slice(0, cap),
}
const promptContext: SystemPromptContext = {
cwd: this.cwd,
ide,
providerInfo,
editorTabs,
supportsBrowserUse,
mcpHub: this.mcpHub,
skills: availableSkills,
+148 -1
View File
@@ -31,7 +31,7 @@ import { sendShowWebviewEvent } from "./core/controller/ui/subscribeToShowWebvie
import { HookDiscoveryCache } from "./core/hooks/HookDiscoveryCache"
import { HookProcessRegistry } from "./core/hooks/HookProcessRegistry"
import { workspaceResolver } from "./core/workspace"
import { getContextForCommand, showWebview } from "./hosts/vscode/commandUtils"
import { findMatchingNotebookCell, getContextForCommand, showWebview } from "./hosts/vscode/commandUtils"
import { abortCommitGeneration, generateCommitMsg } from "./hosts/vscode/commit-message-generator"
import {
disposeVscodeCommentReviewController,
@@ -383,6 +383,107 @@ export async function activate(context: vscode.ExtensionContext) {
}),
)
// Register Jupyter Notebook command handlers
const NOTEBOOK_EDIT_INSTRUCTIONS = `Special considerations for using replace_in_file on *.ipynb files:
* Jupyter notebook files are JSON format with specific structure for source code cells
* Source code in cells is stored as JSON string arrays ending with explicit \\n characters and commas
* Always match the exact JSON format including quotes, commas, and escaped newlines.`
// Helper to get notebook context for Jupyter commands
async function getNotebookCommandContext(range?: vscode.Range, diagnostics?: vscode.Diagnostic[]) {
const activeNotebook = vscode.window.activeNotebookEditor
if (!activeNotebook) {
HostProvider.window.showMessage({
type: ShowMessageType.ERROR,
message: "No active Jupyter notebook found. Please open a .ipynb file first.",
})
return null
}
const ctx = await getContextForCommand(range, diagnostics)
if (!ctx) {
return null
}
const filePath = ctx.commandContext.filePath || ""
let cellJson: string | null = null
if (activeNotebook.notebook.cellCount > 0) {
const cellIndex = activeNotebook.notebook.cellAt(activeNotebook.selection.start).index
cellJson = await findMatchingNotebookCell(filePath, cellIndex)
}
return { ...ctx, cellJson }
}
context.subscriptions.push(
vscode.commands.registerCommand(
commands.JupyterGenerateCell,
async (range?: vscode.Range, diagnostics?: vscode.Diagnostic[]) => {
const userPrompt = await showJupyterPromptInput(
"Generate Notebook Cell",
"Enter your prompt for generating notebook cell (press Enter to confirm & Esc to cancel)",
)
if (!userPrompt) return
const ctx = await getNotebookCommandContext(range, diagnostics)
if (!ctx) return
const notebookContext = `User prompt: ${userPrompt}
Insert a new Jupyter notebook cell above or below the current cell based on user prompt.
${NOTEBOOK_EDIT_INSTRUCTIONS}
Current Notebook Cell Context (JSON, sanitized of image data):
\`\`\`json
${ctx.cellJson || "{}"}
\`\`\``
await addToCline(ctx.controller, ctx.commandContext, notebookContext)
},
),
)
context.subscriptions.push(
vscode.commands.registerCommand(
commands.JupyterExplainCell,
async (range?: vscode.Range, diagnostics?: vscode.Diagnostic[]) => {
const ctx = await getNotebookCommandContext(range, diagnostics)
if (!ctx) return
const notebookContext = ctx.cellJson
? `\n\nCurrent Notebook Cell Context (JSON, sanitized of image data):\n\`\`\`json\n${ctx.cellJson}\n\`\`\``
: undefined
await explainWithCline(ctx.controller, ctx.commandContext, notebookContext)
},
),
)
context.subscriptions.push(
vscode.commands.registerCommand(
commands.JupyterImproveCell,
async (range?: vscode.Range, diagnostics?: vscode.Diagnostic[]) => {
const userPrompt = await showJupyterPromptInput(
"Improve Notebook Cell",
"Enter your prompt for improving the current notebook cell (press Enter to confirm & Esc to cancel)",
)
if (!userPrompt) return
const ctx = await getNotebookCommandContext(range, diagnostics)
if (!ctx) return
const notebookContext = `User prompt: ${userPrompt}
${NOTEBOOK_EDIT_INSTRUCTIONS}
Current Notebook Cell Context (JSON, sanitized of image data):
\`\`\`json
${ctx.cellJson || "{}"}
\`\`\``
await improveWithCline(ctx.controller, ctx.commandContext, notebookContext)
},
),
)
// Register the openWalkthrough command handler
context.subscriptions.push(
vscode.commands.registerCommand(commands.Walkthrough, async () => {
@@ -433,6 +534,52 @@ export async function activate(context: vscode.ExtensionContext) {
return createClineAPI(webview.controller)
}
async function showJupyterPromptInput(title: string, placeholder: string): Promise<string | undefined> {
return new Promise((resolve) => {
const quickPick = vscode.window.createQuickPick()
quickPick.title = title
quickPick.placeholder = placeholder
quickPick.ignoreFocusOut = true
// Allow free text input
quickPick.canSelectMany = false
let userInput = ""
quickPick.onDidChangeValue((value) => {
userInput = value
// Update items to show the current input
if (value) {
quickPick.items = [
{
label: "$(check) Use this prompt",
detail: value,
alwaysShow: true,
},
]
} else {
quickPick.items = []
}
})
quickPick.onDidAccept(() => {
if (userInput) {
resolve(userInput)
quickPick.hide()
}
})
quickPick.onDidHide(() => {
if (!userInput) {
resolve(undefined)
}
quickPick.dispose()
})
quickPick.show()
})
}
function setupHostProvider(context: ExtensionContext) {
console.log("Setting up vscode host providers...")
+5
View File
@@ -90,4 +90,9 @@ export class ExternalDiffViewProvider extends DiffViewProvider {
protected override async resetDiffView(): Promise<void> {
this.activeDiffEditorId = undefined
}
protected async switchToSpecializedEditor(): Promise<void> {
// For external diff view provider, we don't have specialized editor support yet
return
}
}
+179 -2
View File
@@ -2,6 +2,7 @@ import { DiffViewProvider } from "@integrations/editor/DiffViewProvider"
import * as path from "path"
import * as vscode from "vscode"
import { DecorationController } from "@/hosts/vscode/DecorationController"
import { Logger } from "@/services/logging/Logger"
import { arePathsEqual } from "@/utils/path"
export const DIFF_VIEW_URI_SCHEME = "cline-diff"
@@ -12,6 +13,10 @@ export class VscodeDiffViewProvider extends DiffViewProvider {
private fadedOverlayController?: DecorationController
private activeLineController?: DecorationController
// Temporary file management for notebook diff views
private tempModifiedUri?: vscode.Uri
private tempFileWatcher?: vscode.FileSystemWatcher
override async openDiffEditor(): Promise<void> {
if (!this.absolutePath) {
throw new Error("No file path set")
@@ -96,6 +101,7 @@ export class VscodeDiffViewProvider extends DiffViewProvider {
if (!this.activeDiffEditor || !this.activeDiffEditor.document) {
throw new Error("User closed text editor, unable to edit file...")
}
// Place cursor at the beginning of the diff editor to keep it out of the way of the stream animation
const beginningOfDocument = new vscode.Position(0, 0)
this.activeDiffEditor.selection = new vscode.Selection(beginningOfDocument, beginningOfDocument)
@@ -170,11 +176,156 @@ export class VscodeDiffViewProvider extends DiffViewProvider {
return true
}
protected override async switchToSpecializedEditor(): Promise<void> {
if (!this.isNotebookFile() || !this.activeDiffEditor || !this.absolutePath) {
Logger.log(
`switchToSpecializedEditor: Early return - isNotebook: ${this.isNotebookFile()}, hasActiveDiffEditor: ${!!this.activeDiffEditor}, hasAbsolutePath: ${!!this.absolutePath}`,
)
return
}
try {
const uri = vscode.Uri.file(this.absolutePath)
const fileName = path.basename(uri.fsPath)
Logger.log(`Attempting to create notebook diff view for file: ${fileName}`)
// Check if Jupyter extension is available
const jupyterExtension = vscode.extensions.getExtension("ms-toolsai.jupyter")
if (!jupyterExtension) {
Logger.log("Jupyter extension not found, cannot create notebook diff view")
return
}
if (!jupyterExtension.isActive) {
Logger.log("Jupyter extension not active, activating...")
await jupyterExtension.activate()
}
// Create a proper notebook diff view by creating temporary files
await this.createNotebookDiffView(uri, fileName)
} catch (error) {
Logger.error("Failed to create notebook diff view, continuing with text editor:", error)
// Text editor remains active - no changes needed
}
}
/**
* Create a proper notebook diff view using temporary files
*/
private async createNotebookDiffView(uri: vscode.Uri, fileName: string): Promise<void> {
try {
Logger.log("Creating notebook diff view with temporary file...")
// Create temporary directory and file for modified content (right side)
const tempDir = require("os").tmpdir()
const timestamp = Date.now()
const tempModifiedPath = path.join(tempDir, `cline-modified-${timestamp}-${fileName}`)
// Write current editor content to temporary file
const currentContent = this.activeDiffEditor?.document.getText() ?? ""
try {
// Attempt to parse the content as JSON
JSON.parse(currentContent)
} catch (error) {
Logger.error(`Invalid JSON content for notebook file ${fileName}, skipping notebook diff view creation`)
Logger.error(`JSON parse error: ${error}`)
return
}
await vscode.workspace.fs.writeFile(vscode.Uri.file(tempModifiedPath), new TextEncoder().encode(currentContent))
// Store temporary file URI for cleanup
this.tempModifiedUri = vscode.Uri.file(tempModifiedPath)
Logger.log(`Created temporary modified file: ${tempModifiedPath}`)
// Close current text diff editor
// await this.closeCurrentTextDiffEditor()
// Set up file system watcher for synchronization
this.setupTempDocumentListener()
// Open notebook diff view with original file (left) vs temporary file (right)
Logger.log("Opening notebook diff view...")
await vscode.commands.executeCommand(
"vscode.diff",
uri, // Left: original file
this.tempModifiedUri, // Right: temporary file with modifications
`${fileName}: Original ↔ Cline's Changes (Notebook)`,
)
// Give VS Code a moment to open the notebook diff view
await new Promise((resolve) => setTimeout(resolve, 500))
Logger.log("Notebook diff view opened successfully")
} catch (error) {
Logger.error(`Error creating notebook diff view: ${error}`)
// Clean up on error
await this.cleanupTempFiles()
return
}
}
/**
* Set up file system watcher for temporary file synchronization
*/
private setupTempDocumentListener(): void {
if (this.tempModifiedUri) {
this.tempFileWatcher = vscode.workspace.createFileSystemWatcher(
new vscode.RelativePattern(this.tempModifiedUri.fsPath, "*"),
)
this.tempFileWatcher.onDidChange(async () => {
await this.syncTempFileToActiveDiffEditor()
})
Logger.log(`File system watcher set up for temp file: ${this.tempModifiedUri.fsPath}`)
}
}
/**
* Sync changes from temporary file back to active diff editor only
*/
private async syncTempFileToActiveDiffEditor(): Promise<void> {
if (this.tempModifiedUri && this.activeDiffEditor && this.activeDiffEditor.document) {
try {
const tempContent = await vscode.workspace.fs.readFile(this.tempModifiedUri)
const tempContentString = new TextDecoder().decode(tempContent)
// Update text editor content to match temporary file
const edit = new vscode.WorkspaceEdit()
const fullRange = new vscode.Range(0, 0, this.activeDiffEditor.document.lineCount, 0)
edit.replace(this.activeDiffEditor.document.uri, fullRange, tempContentString)
await vscode.workspace.applyEdit(edit)
Logger.log("Synced temp file changes back to active diff editor")
} catch (error) {
Logger.error("Failed to sync temp file to active diff editor:", error)
}
}
}
protected async closeAllDiffViews(): Promise<void> {
// Close all the cline diff views.
// Close all the cline diff views (both text and notebook diff views).
const tabs = vscode.window.tabGroups.all
.flatMap((tg) => tg.tabs)
.filter((tab) => tab.input instanceof vscode.TabInputTextDiff && tab.input?.original?.scheme === DIFF_VIEW_URI_SCHEME)
.filter((tab) => {
// Regular Cline text diff views
if (tab.input instanceof vscode.TabInputTextDiff && tab.input?.original?.scheme === DIFF_VIEW_URI_SCHEME) {
return true
}
// Notebook diff views created by createNotebookDiffView()
if (
tab.input instanceof vscode.TabInputNotebookDiff &&
tab.input?.modified?.fsPath?.includes("cline-modified-")
) {
return true
}
return false
})
for (const tab of tabs) {
// trying to close dirty views results in save popup
if (!tab.isDirty) {
@@ -188,8 +339,34 @@ export class VscodeDiffViewProvider extends DiffViewProvider {
}
protected override async resetDiffView(): Promise<void> {
// Clean up temporary files and listeners (basic cleanup for now)
await this.cleanupTempFiles()
this.activeDiffEditor = undefined
this.fadedOverlayController = undefined
this.activeLineController = undefined
}
/**
* Clean up temporary files and watchers (basic implementation)
*/
private async cleanupTempFiles(): Promise<void> {
// Dispose file watcher first
if (this.tempFileWatcher) {
this.tempFileWatcher.dispose()
this.tempFileWatcher = undefined
}
// Clean up temporary file
if (this.tempModifiedUri) {
try {
await vscode.workspace.fs.delete(this.tempModifiedUri)
Logger.log(`Cleaned up temporary file: ${this.tempModifiedUri.fsPath}`)
} catch (error) {
// Log but don't throw - cleanup should be non-blocking
Logger.log(`Failed to cleanup temporary file: ${error}`)
}
this.tempModifiedUri = undefined
}
}
}
+48 -1
View File
@@ -1,10 +1,49 @@
import * as fs from "fs/promises"
import * as vscode from "vscode"
import { sanitizeCellForLLM } from "@/integrations/misc/notebook-utils"
import { ExtensionRegistryInfo } from "@/registry"
import { Logger } from "@/services/logging/Logger"
import { CommandContext } from "@/shared/proto/index.cline"
import { Controller } from "../../core/controller"
import { WebviewProvider } from "../../core/webview"
import { convertVscodeDiagnostics } from "./hostbridge/workspace/getDiagnostics"
/**
* Finds the notebook cell that contains the selected text and returns its JSON representation
* @param filePath Path to the .ipynb file
* @param notebookCell The cell index from the active notebook editor
* @returns JSON string of the matching cell, or null if no match found
*/
export async function findMatchingNotebookCell(filePath: string, notebookCell?: number): Promise<string | null> {
try {
// Read the notebook file directly
const notebookContent = await fs.readFile(filePath, "utf8")
const notebook = JSON.parse(notebookContent)
if (!notebook.cells || !Array.isArray(notebook.cells)) {
Logger.log("Invalid notebook structure: no cells array found")
return null
}
Logger.log(`Loaded notebook with ${notebook.cells.length} cells`)
if (typeof notebookCell === "number" && notebookCell >= 0 && notebookCell < notebook.cells.length) {
Logger.log(`Using provided notebook cell number ${notebookCell}`)
// Get a reference to the specific cell object
const cellToProcess = notebook.cells[notebookCell]
// Sanitize the cell outputs (truncate images, keep text outputs)
return sanitizeCellForLLM(cellToProcess)
}
Logger.log("No valid notebook cell number provided")
return null
} catch (error) {
Logger.error("Error in findMatchingNotebookCell:", error)
return null
}
}
/**
* Gets the context needed for VSCode commands that interact with the editor
* @param range Optional range to use instead of current selection
@@ -34,7 +73,14 @@ export async function getContextForCommand(
const editor = vscode.window.activeTextEditor
if (!editor) {
return
// Fallback for notebooks with no cells (no text editor active)
const activeNotebook = vscode.window.activeNotebookEditor
if (!activeNotebook) {
return
}
const filePath = activeNotebook.notebook.uri.fsPath
const diagnostics = convertVscodeDiagnostics(vscodeDiagnostics || [])
return { controller, commandContext: { selectedText: "", filePath, diagnostics, language: "" } }
}
// Use provided range if available, otherwise use current selection
// (vscode command passes an argument in the first param by default, so we need to ensure it's a Range object)
@@ -50,6 +96,7 @@ export async function getContextForCommand(
diagnostics,
language,
}
return { controller, commandContext }
}
+27 -1
View File
@@ -150,6 +150,15 @@ export abstract class DiffViewProvider {
*/
protected abstract resetDiffView(): Promise<void>
/**
* Switches to a specialized editor for specific file types after final content is available.
* Called automatically by the `update` method when `isFinal` is true.
*
* For example, switches to Jupyter notebook editor for .ipynb files to provide
* enhanced editing experience with proper notebook cell rendering.
*/
protected abstract switchToSpecializedEditor(): Promise<void>
async update(
accumulatedContent: string,
isFinal: boolean,
@@ -221,6 +230,9 @@ export abstract class DiffViewProvider {
accumulatedContent += "\n"
}
}
// Switch to specialized editor for specific file types (e.g., Jupyter for .ipynb)
await this.switchToSpecializedEditor()
}
}
@@ -246,6 +258,15 @@ export abstract class DiffViewProvider {
currentLine: number | undefined,
): Promise<void>
/**
* Checks if the current file is a Jupyter notebook file.
*
* @returns true if the file has .ipynb extension
*/
protected isNotebookFile(): boolean {
return this.relPath?.toLowerCase().endsWith(".ipynb") ?? false
}
async saveChanges(): Promise<{
newProblemsMessage: string | undefined
userEdits: string | undefined
@@ -268,7 +289,12 @@ export abstract class DiffViewProvider {
// get text after save in case there is any auto-formatting done by the editor
const postSaveContent = (await this.getDocumentText()) || ""
await this.showFile(this.absolutePath)
// we need to open notebook files with Notebook editor if available.
// Currently, HostProvider opens it with Text editor. Not opening
// notebook files until we fix that.
if (!this.isNotebookFile()) {
await this.showFile(this.absolutePath)
}
await this.closeAllDiffViews()
const newProblems = await this.getNewDiagnosticProblems()
@@ -113,4 +113,9 @@ export class FileEditProvider extends DiffViewProvider {
// Clean up the in-memory document content
this.documentContent = undefined
}
protected async switchToSpecializedEditor(): Promise<void> {
// No-op: File-system-only provider doesn't support visual specialized editors
// All operations are performed directly on the file system
}
}
+3 -9
View File
@@ -7,6 +7,7 @@ import mammoth from "mammoth"
import * as path from "path"
// @ts-ignore-next-line
import pdf from "pdf-parse/lib/pdf-parse"
import { sanitizeNotebookForLLM } from "./notebook-utils"
export async function detectEncoding(fileBuffer: Buffer, fileExtension?: string): Promise<string> {
const detected = chardet.detect(fileBuffer)
@@ -76,16 +77,9 @@ async function extractTextFromIPYNB(filePath: string): Promise<string> {
const fileBuffer = await fs.readFile(filePath)
const encoding = await detectEncoding(fileBuffer)
const data = iconv.decode(fileBuffer, encoding)
const notebook = JSON.parse(data)
let extractedText = ""
for (const cell of notebook.cells) {
if ((cell.cell_type === "markdown" || cell.cell_type === "code") && cell.source) {
extractedText += cell.source.join("\n") + "\n"
}
}
return extractedText
// Return sanitized JSON for proper editing (enhanced notebook behavior is now always enabled)
return sanitizeNotebookForLLM(data)
}
/**
+78
View File
@@ -0,0 +1,78 @@
/**
* Shared utilities for processing Jupyter notebooks for LLM context.
* Used by both the context menu commands (addToCline, etc.) and file reading (extract-text.ts).
*/
/**
* Image MIME types that should be truncated in notebook outputs
*/
const IMAGE_MIME_TYPES = ["image/png", "image/jpeg", "image/gif", "image/svg+xml", "image/webp"]
/**
* Sanitizes the outputs of a single notebook cell by truncating image data.
* Keeps text outputs intact for context, only replaces binary image data with placeholders.
*
* @param cell A notebook cell object
* @returns The cell with sanitized outputs
*/
export function sanitizeCellOutputs(cell: Record<string, unknown>): Record<string, unknown> {
if (cell.cell_type !== "code" || !cell.outputs || !Array.isArray(cell.outputs)) {
return cell
}
const sanitizedOutputs = cell.outputs.map((output: Record<string, unknown>) => {
// Handle display_data and execute_result outputs with data field
if (output.data && typeof output.data === "object") {
const data = output.data as Record<string, unknown>
const sanitizedData = { ...data }
for (const mimeType of IMAGE_MIME_TYPES) {
if (mimeType in sanitizedData) {
sanitizedData[mimeType] = "[IMAGE DATA TRUNCATED]"
}
}
return { ...output, data: sanitizedData }
}
return output
})
return { ...cell, outputs: sanitizedOutputs }
}
/**
* Sanitizes a Jupyter notebook JSON by truncating verbose image data in cell outputs.
* This prevents flooding the LLM context with large base64-encoded images that are
* not useful for editing (outputs are regenerated when code runs).
*
* @param jsonString The raw notebook JSON string
* @returns Sanitized JSON string with image data truncated
*/
export function sanitizeNotebookForLLM(jsonString: string): string {
try {
const notebook = JSON.parse(jsonString)
if (!notebook.cells || !Array.isArray(notebook.cells)) {
return jsonString
}
notebook.cells = notebook.cells.map((cell: Record<string, unknown>) => sanitizeCellOutputs(cell))
return JSON.stringify(notebook, null, 1)
} catch {
// If parsing fails, return original string
return jsonString
}
}
/**
* Sanitizes a single notebook cell object and returns it as a JSON string.
* Used by context menu commands that work with individual cells.
*
* @param cell A notebook cell object
* @returns JSON string of the sanitized cell
*/
export function sanitizeCellForLLM(cell: Record<string, unknown>): string {
const sanitized = sanitizeCellOutputs(cell)
return JSON.stringify(sanitized, null, 2)
}
+4
View File
@@ -24,6 +24,10 @@ const ClineCommands = {
GenerateCommit: prefix + ".generateGitCommitMessage",
AbortCommit: prefix + ".abortGitCommitMessage",
ReconstructTaskHistory: prefix + ".reconstructTaskHistory",
// Jupyter Notebook commands
JupyterGenerateCell: prefix + ".jupyterGenerateCell",
JupyterExplainCell: prefix + ".jupyterExplainCell",
JupyterImproveCell: prefix + ".jupyterImproveCell",
}
/**
@@ -169,7 +169,7 @@ const FeatureSettingsSection = ({ renderSectionHeader }: FeatureSettingsSectionP
</div>
)}
<div>
<div style={{ marginTop: 10 }}>
<VSCodeCheckbox
checked={enableCheckpointsSetting}
onChange={(e: any) => {