Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions extensions/markdown-language-features/preview-src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ let documentVersion = 0;
let documentResource = settings.settings.source;

const vscode = acquireVsCodeApi();
// Expose for injected scripts (like plan execution button)
(window as any).vscodeApi = vscode;

// eslint-disable-next-line local/code-no-any-casts
const originalState = vscode.getState() ?? {} as any;
Expand Down Expand Up @@ -221,6 +223,43 @@ window.addEventListener('message', async event => {
}
return;

case 'updatePlanProgress': {
if (data.source === documentResource) {
const progressText = document.getElementById('plan-progress-text');
const progressBar = document.getElementById('plan-progress-bar');
const todoStats = document.getElementById('plan-todo-stats');

if (progressText) {
progressText.textContent = `${data.progress}%`;
}
if (progressBar) {
progressBar.style.width = `${data.progress}%`;
}
if (todoStats) {
todoStats.textContent = `${data.completedTodos}/${data.totalTodos} todos`;
}

// Update execution status if provided
if (data.status) {
const button = document.getElementById('start-execution-btn');
if (button) {
const statusLabels: Record<string, string> = {
'not-started': '▶ Start Execution',
'starting': '⏳ Starting...',
'in-progress': '⏸ Pause Execution',
'completed': '✓ Completed',
'failed': '✗ Failed'
};
button.textContent = statusLabels[data.status] || '▶ Start Execution';
if (data.status === 'completed' || data.status === 'failed') {
button.disabled = true;
}
}
}
}
return;
}

case 'updateContent': {
const root = document.querySelector('.markdown-body')!;

Expand Down
214 changes: 197 additions & 17 deletions extensions/markdown-language-features/src/preview/documentRenderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,40 +121,220 @@ export class MdDocumentRenderer {
): Promise<MarkdownContentProviderOutput> {
const rendered = await this._engine.render(markdownDocument, resourceProvider);

// Inject "Start Execution" button for .plan.md files
// Inject enhanced plan preview for .plan.md files
let planFileHeader = '';
let planProgressScript = '';
if (markdownDocument.uri.fsPath.endsWith('.plan.md')) {
const sourceUri = markdownDocument.uri.toString();
const scriptNonce = nonce || getNonce();
const planContent = markdownDocument.getText();
const planStats = this.parsePlanStats(planContent);

// Extract todos for display
const todos = this.extractTodosForDisplay(planContent);
const incompleteTodos = todos.filter(t => !t.completed);

planFileHeader = `
<div class="plan-execution-header" style="position: sticky; top: 0; z-index: 1000; background: var(--vscode-editor-background); padding: 12px; border-bottom: 1px solid var(--vscode-panel-border); margin-bottom: 16px;">
<button id="start-execution-btn" style="background: var(--vscode-button-background); color: var(--vscode-button-foreground); border: none; padding: 8px 16px; border-radius: 4px; cursor: pointer; font-size: 14px; font-weight: 500;">
▶ Start Execution
</button>
<script nonce="${scriptNonce}">
(function() {
const button = document.getElementById('start-execution-btn');
if (button) {
button.addEventListener('click', () => {
const vscode = acquireVsCodeApi();
vscode.postMessage({
<div style="display: flex; align-items: center; gap: 16px; flex-wrap: wrap; margin-bottom: 12px;">
<button id="start-execution-btn" style="background: var(--vscode-button-background); color: var(--vscode-button-foreground); border: none; padding: 8px 16px; border-radius: 4px; cursor: pointer; font-size: 14px; font-weight: 500;">
▶ Start Execution
</button>
<div id="execution-status-badge" style="padding: 4px 8px; border-radius: 4px; font-size: 11px; font-weight: 500; background: var(--vscode-badge-background); color: var(--vscode-badge-foreground); display: none;">
<span id="execution-status-text">Not Started</span>
</div>
<div style="flex: 1; min-width: 200px;">
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 4px;">
<span style="font-size: 12px; color: var(--vscode-descriptionForeground);">Progress:</span>
<span id="plan-progress-text" style="font-size: 12px; font-weight: 500;">${planStats.progress}%</span>
</div>
<div style="width: 100%; height: 6px; background: var(--vscode-progressBar-background); border-radius: 3px; overflow: hidden;">
<div id="plan-progress-bar" style="height: 100%; width: ${planStats.progress}%; background: var(--vscode-progressBar-foreground); transition: width 0.3s ease;"></div>
</div>
</div>
<div style="font-size: 12px; color: var(--vscode-descriptionForeground);">
<span id="plan-todo-stats">${planStats.completedTodos}/${planStats.totalTodos} todos</span>
</div>
</div>
${incompleteTodos.length > 0 ? `
<div style="border-top: 1px solid var(--vscode-panel-border); padding-top: 12px; margin-top: 8px;">
<div style="font-size: 12px; font-weight: 600; margin-bottom: 8px; color: var(--vscode-foreground);">Todos (${incompleteTodos.length} remaining):</div>
<div style="max-height: 150px; overflow-y: auto; font-size: 12px;">
${incompleteTodos.map(todo => `
<div style="padding: 4px 0; display: flex; align-items: start; gap: 8px;">
<span style="color: var(--vscode-descriptionForeground);">☐</span>
<span style="flex: 1; color: var(--vscode-foreground);">${this.escapeHtml(todo.text)}</span>
</div>
`).join('')}
</div>
</div>
` : ''}
</div>
`;

planProgressScript = `
<script nonce="${scriptNonce}">
(function() {
const button = document.getElementById('start-execution-btn');
if (button) {
button.addEventListener('click', () => {
console.log('[Plan Execution] Button clicked for:', '${sourceUri}');
// Use existing API if available (exposed from preview-src/index.ts)
const vscode = (window as any).vscodeApi;
if (!vscode) {
console.error('[Plan Execution] VS Code API not available');
alert('VS Code API not available. Please refresh the preview.');
return;
}

try {
const message = {
type: 'startExecution',
source: '${sourceUri}'
});
});
};
console.log('[Plan Execution] Sending message:', message);
vscode.postMessage(message);
console.log('[Plan Execution] Message sent successfully');
} catch (error) {
console.error('[Plan Execution] Failed to send message:', error);
alert('Failed to send execution request: ' + (error instanceof Error ? error.message : String(error)));
}
});
}

// Enhance todos with interactive checkboxes
const todoRegex = /<li[^>]*>\\s*<input[^>]*type=["']checkbox["'][^>]*>/gi;
const listItems = document.querySelectorAll('li');
listItems.forEach(li => {
const text = li.textContent || '';
if (/^\\s*\\[\\s*\\]/.test(text) || /^\\s*\\[x\\]/i.test(text)) {
li.classList.add('plan-todo-item');
const isChecked = /^\\s*\\[x\\]/i.test(text);
if (isChecked) {
li.style.opacity = '0.6';
li.style.textDecoration = 'line-through';
}
}
})();
</script>
</div>
});

// Add section progress indicators
const sections = document.querySelectorAll('h2');
sections.forEach(section => {
const sectionId = section.id || section.textContent?.toLowerCase().replace(/\\s+/g, '-') || '';
const sectionContent = section.nextElementSibling;
if (sectionContent) {
const todos = sectionContent.querySelectorAll('.plan-todo-item');
if (todos.length > 0) {
const completed = Array.from(todos).filter(t => t.style.textDecoration === 'line-through').length;
const total = todos.length;
const progress = total > 0 ? Math.round((completed / total) * 100) : 0;

const progressBadge = document.createElement('span');
progressBadge.style.cssText = 'margin-left: 8px; font-size: 11px; color: var(--vscode-descriptionForeground);';
progressBadge.textContent = \`[\${completed}/\${total}]\`;
section.appendChild(progressBadge);
}
}
});

// Listen for progress updates
window.addEventListener('message', (event) => {
const data = event.data;
if (data && data.type === 'updatePlanProgress' && data.source === '${sourceUri}') {
const progressText = document.getElementById('plan-progress-text');
const progressBar = document.getElementById('plan-progress-bar');
const todoStats = document.getElementById('plan-todo-stats');
const statusBadge = document.getElementById('execution-status-badge');
const statusText = document.getElementById('execution-status-text');
const button = document.getElementById('start-execution-btn');

if (progressText) progressText.textContent = data.progress + '%';
if (progressBar) progressBar.style.width = data.progress + '%';
if (todoStats) todoStats.textContent = data.completedTodos + '/' + data.totalTodos + ' todos';

if (data.status && statusBadge && statusText) {
const statusLabels = {
'not-started': 'Not Started',
'starting': 'Starting...',
'in-progress': 'Executing',
'completed': 'Completed',
'failed': 'Failed'
};
statusText.textContent = statusLabels[data.status] || 'Unknown';
statusBadge.style.display = 'block';

if (button) {
const buttonLabels = {
'not-started': '▶ Start Execution',
'starting': '⏳ Starting...',
'in-progress': '⏸ Pause Execution',
'completed': '✓ Completed',
'failed': '✗ Failed'
};
button.textContent = buttonLabels[data.status] || '▶ Start Execution';
if (data.status === 'completed' || data.status === 'failed') {
button.disabled = true;
}
}
}
}
});
})();
</script>
`;
}

const html = `${planFileHeader}<div class="markdown-body" dir="auto">${rendered.html}<div class="code-line" data-line="${markdownDocument.lineCount}"></div></div>`;
const html = `${planFileHeader}<div class="markdown-body" dir="auto">${rendered.html}<div class="code-line" data-line="${markdownDocument.lineCount}"></div></div>${planProgressScript}`;
return {
html,
containingImages: rendered.containingImages
};
}

/**
* Parse plan statistics from markdown content
*/
private parsePlanStats(content: string): { totalTodos: number; completedTodos: number; progress: number } {
const todos = this.extractTodosForDisplay(content);
const totalTodos = todos.length;
const completedTodos = todos.filter(t => t.completed).length;
const progress = totalTodos > 0 ? Math.round((completedTodos / totalTodos) * 100) : 0;

return { totalTodos, completedTodos, progress };
}

/**
* Extract todos from plan content for display
*/
private extractTodosForDisplay(content: string): Array<{ text: string; completed: boolean }> {
const todos: Array<{ text: string; completed: boolean }> = [];
const todoPatterns = [
/^\s*[-*]\s*\[([\sx])\]\s*(.+)$/gim, // Standard: - [ ] or - [x]
/^\s*\d+\.\s*\[([\sx])\]\s*(.+)$/gim, // Numbered: 1. [ ] or 1. [x]
];

for (const pattern of todoPatterns) {
pattern.lastIndex = 0;
let match;
while ((match = pattern.exec(content)) !== null) {
todos.push({
text: match[2].trim(),
completed: match[1].toLowerCase() === 'x'
});
}
}

return todos;
}

/**
* Escape HTML to prevent XSS
*/
private escapeHtml(text: string): string {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}

public renderFileNotFoundDocument(resource: vscode.Uri): string {
const resourcePath = uri.Utils.basename(resource);
Expand Down
11 changes: 10 additions & 1 deletion extensions/markdown-language-features/src/preview/preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,16 @@ class MarkdownPreview extends Disposable implements WebviewResourceProvider {
break;

case 'startExecution':
vscode.commands.executeCommand('workbench.action.chat.startPlanExecution', e.source);
this._logger.trace('Plan Execution', `Message received in preview.ts, source: ${e.source}`);
Promise.resolve(vscode.commands.executeCommand('workbench.action.chat.startPlanExecution', e.source))
.then((result) => {
this._logger.trace('Plan Execution', `Command executed successfully, result: ${result}`);
})
.catch((error: unknown) => {
const errorMessage = error instanceof Error ? error.message : String(error);
this._logger.trace('Plan Execution', `Command execution failed: ${errorMessage}`);
vscode.window.showErrorMessage(`Failed to start plan execution: ${errorMessage}`);
});
break;
}
}));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,11 +83,20 @@ export namespace ToWebviewMessage {
readonly imageSource: string;
}

export interface UpdatePlanProgress extends BaseMessage {
readonly type: 'updatePlanProgress';
readonly progress: number;
readonly completedTodos: number;
readonly totalTodos: number;
readonly status?: 'not-started' | 'starting' | 'in-progress' | 'completed' | 'failed';
}

export type Type =
| OnDidChangeTextEditorSelection
| UpdateView
| UpdateContent
| CopyImageContent
| OpenImageContent
| UpdatePlanProgress
;
}
2 changes: 2 additions & 0 deletions src/vs/workbench/contrib/chat/browser/chat.contribution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import { ChatService } from '../common/chatServiceImpl.js';
import { IChatSessionsService } from '../common/chatSessionsService.js';
import { ChatSlashCommandService, IChatSlashCommandService } from '../common/chatSlashCommands.js';
import { ChatTodoListService, IChatTodoListService } from '../common/chatTodoListService.js';
import { PlanExecutionTracker, IPlanExecutionTracker } from '../common/planExecutionTracker.js';
import { ChatTransferService, IChatTransferService } from '../common/chatTransferService.js';
import { IChatVariablesService } from '../common/chatVariables.js';
import { ChatWidgetHistoryService, IChatWidgetHistoryService } from '../common/chatWidgetHistoryService.js';
Expand Down Expand Up @@ -1079,6 +1080,7 @@ registerSingleton(IChatContextPickService, ChatContextPickService, Instantiation
registerSingleton(IChatModeService, ChatModeService, InstantiationType.Delayed);
registerSingleton(IChatAttachmentResolveService, ChatAttachmentResolveService, InstantiationType.Delayed);
registerSingleton(IChatTodoListService, ChatTodoListService, InstantiationType.Delayed);
registerSingleton(IPlanExecutionTracker, PlanExecutionTracker, InstantiationType.Delayed);
registerSingleton(IChatOutputRendererService, ChatOutputRendererService, InstantiationType.Delayed);
registerSingleton(IChatLayoutService, ChatLayoutService, InstantiationType.Delayed);

Expand Down
30 changes: 28 additions & 2 deletions src/vs/workbench/contrib/chat/browser/gemini/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -821,10 +821,15 @@ Only call a tool if it is necessary; otherwise respond normally.`,
(id) => selected[id] === true
);
if (!allowedIds.length) {
// Empty selection - fall back to default behavior
this.logService.debug(
`[gemini] tool selection for request ${requestId} contained no enabled entries`
`[gemini] tool selection for request ${requestId} contained no enabled entries, falling back to default`
);
return [];
const allTools = Array.from(this.languageModelToolsService.getTools());
if (chatMode === ChatModeKind.Plan) {
return allTools.filter((tool) => tool.id !== "create_file");
}
return allTools;
}
const allowedSet = new Set(allowedIds);
const allowedTools: IToolData[] = [];
Expand All @@ -833,6 +838,22 @@ Only call a tool if it is necessary; otherwise respond normally.`,
allowedTools.push(tool);
}
}

// CRITICAL FIX: If filtering resulted in zero tools, fall back to default
// This prevents empty tool arrays from being sent to Gemini, which would prevent tool calls
if (allowedTools.length === 0) {
this.logService.warn(
`[gemini] Tool filtering for request ${requestId} resulted in zero tools (selected IDs: ${allowedIds.join(
", "
)}), falling back to default behavior`
);
const allTools = Array.from(this.languageModelToolsService.getTools());
if (chatMode === ChatModeKind.Plan) {
return allTools.filter((tool) => tool.id !== "create_file");
}
return allTools;
}

this.logService.debug(
`[gemini] resolved ${
allowedTools.length
Expand All @@ -853,6 +874,11 @@ Only call a tool if it is necessary; otherwise respond normally.`,
} {
const allowedTools = this.getAllowedToolData(requestId, chatMode);
if (!allowedTools.length) {
this.logService.warn(
`[gemini] buildGeminiToolDeclarations: No tools available for request ${requestId} in mode ${
chatMode || "unknown"
}. This will prevent tool calls.`
);
return { tools: [], nameToToolId: new Map(), summaries: [] };
}

Expand Down
Loading
Loading