feat(workflow): add multi-CLI collaborative planning command

- Introduced a new command `/workflow:multi-cli-plan` for collaborative planning using ACE semantic search and iterative analysis with Claude and Codex.
- Implemented a structured execution flow with phases for context gathering, multi-tool analysis, user decision points, and final plan generation.
- Added detailed documentation outlining the command's usage, execution phases, and key features.
- Included error handling and configuration options for enhanced user experience.
This commit is contained in:
catlog22
2026-01-13 23:23:09 +08:00
parent 2f1c56285a
commit c3da637849
7 changed files with 2669 additions and 196 deletions

View File

@@ -416,5 +416,107 @@ export async function handleSystemRoutes(ctx: SystemRouteContext): Promise<boole
return true;
}
// API: File dialog - list directory contents for file browser
if (pathname === '/api/dialog/browse' && req.method === 'POST') {
handlePostRequest(req, res, async (body) => {
const { path: browsePath, showHidden } = body as {
path?: string;
showHidden?: boolean;
};
const os = await import('os');
const path = await import('path');
const fs = await import('fs');
// Default to home directory
let targetPath = browsePath || os.homedir();
// Expand ~ to home directory
if (targetPath.startsWith('~')) {
targetPath = path.join(os.homedir(), targetPath.slice(1));
}
// Resolve to absolute path
if (!path.isAbsolute(targetPath)) {
targetPath = path.resolve(targetPath);
}
try {
const stat = await fs.promises.stat(targetPath);
if (!stat.isDirectory()) {
return { error: 'Path is not a directory', status: 400 };
}
const entries = await fs.promises.readdir(targetPath, { withFileTypes: true });
const items = entries
.filter(entry => showHidden || !entry.name.startsWith('.'))
.map(entry => ({
name: entry.name,
path: path.join(targetPath, entry.name),
isDirectory: entry.isDirectory(),
isFile: entry.isFile()
}))
.sort((a, b) => {
// Directories first, then files
if (a.isDirectory && !b.isDirectory) return -1;
if (!a.isDirectory && b.isDirectory) return 1;
return a.name.localeCompare(b.name);
});
return {
currentPath: targetPath,
parentPath: path.dirname(targetPath),
items,
homePath: os.homedir()
};
} catch (err) {
return { error: 'Cannot access directory: ' + (err as Error).message, status: 400 };
}
});
return true;
}
// API: File dialog - select file (validate path exists)
if (pathname === '/api/dialog/open-file' && req.method === 'POST') {
handlePostRequest(req, res, async (body) => {
const { path: filePath } = body as { path?: string };
if (!filePath) {
return { error: 'Path is required', status: 400 };
}
const os = await import('os');
const path = await import('path');
const fs = await import('fs');
let targetPath = filePath;
// Expand ~ to home directory
if (targetPath.startsWith('~')) {
targetPath = path.join(os.homedir(), targetPath.slice(1));
}
// Resolve to absolute path
if (!path.isAbsolute(targetPath)) {
targetPath = path.resolve(targetPath);
}
try {
await fs.promises.access(targetPath, fs.constants.R_OK);
const stat = await fs.promises.stat(targetPath);
return {
success: true,
path: targetPath,
isFile: stat.isFile(),
isDirectory: stat.isDirectory()
};
} catch {
return { error: 'File not accessible', status: 404 };
}
});
return true;
}
return false;
}

View File

@@ -661,3 +661,120 @@
color: hsl(var(--success));
}
/* ========================================
* File Browser Modal
* ======================================== */
.file-browser-modal {
width: 600px;
max-width: 90vw;
max-height: 80vh;
display: flex;
flex-direction: column;
}
.file-browser-toolbar {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem;
background: hsl(var(--muted) / 0.3);
border-radius: 0.375rem;
margin-bottom: 0.75rem;
}
.file-browser-toolbar .btn-sm {
flex-shrink: 0;
padding: 0.375rem;
}
.file-browser-path {
flex: 1;
padding: 0.375rem 0.5rem;
font-family: monospace;
font-size: 0.75rem;
background: hsl(var(--background));
border: 1px solid hsl(var(--border));
border-radius: 0.25rem;
color: hsl(var(--foreground));
}
.file-browser-hidden-toggle {
display: flex;
align-items: center;
gap: 0.375rem;
font-size: 0.75rem;
color: hsl(var(--muted-foreground));
cursor: pointer;
white-space: nowrap;
}
.file-browser-hidden-toggle input {
cursor: pointer;
}
.file-browser-list {
flex: 1;
min-height: 300px;
max-height: 400px;
overflow-y: auto;
border: 1px solid hsl(var(--border));
border-radius: 0.375rem;
background: hsl(var(--background));
}
.file-browser-loading,
.file-browser-empty,
.file-browser-error {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
min-height: 200px;
color: hsl(var(--muted-foreground));
font-size: 0.875rem;
}
.file-browser-error {
color: hsl(var(--destructive));
}
.file-browser-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
cursor: pointer;
border-bottom: 1px solid hsl(var(--border) / 0.5);
transition: background-color 0.15s;
}
.file-browser-item:last-child {
border-bottom: none;
}
.file-browser-item:hover {
background: hsl(var(--muted) / 0.5);
}
.file-browser-item.selected {
background: hsl(var(--primary) / 0.15);
border-color: hsl(var(--primary) / 0.3);
}
.file-browser-item.is-directory {
color: hsl(var(--primary));
}
.file-browser-item.is-file {
color: hsl(var(--foreground));
}
.file-browser-item-name {
flex: 1;
font-size: 0.8125rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

View File

@@ -268,6 +268,12 @@ const i18n = {
'cli.envFilePlaceholder': 'Path to .env file (e.g., ~/.gemini-env or C:/Users/xxx/.env)',
'cli.envFileHint': 'Load environment variables (e.g., API keys) before CLI execution. Supports ~ for home directory.',
'cli.envFileBrowse': 'Browse',
'cli.fileBrowser': 'File Browser',
'cli.fileBrowserSelect': 'Select',
'cli.fileBrowserCancel': 'Cancel',
'cli.fileBrowserUp': 'Parent Directory',
'cli.fileBrowserHome': 'Home',
'cli.fileBrowserShowHidden': 'Show hidden files',
// CodexLens Configuration
'codexlens.config': 'CodexLens Configuration',
@@ -2442,6 +2448,12 @@ const i18n = {
'cli.envFilePlaceholder': '.env 文件路径(如 ~/.gemini-env 或 C:/Users/xxx/.env',
'cli.envFileHint': '在 CLI 执行前加载环境变量(如 API 密钥)。支持 ~ 表示用户目录。',
'cli.envFileBrowse': '浏览',
'cli.fileBrowser': '文件浏览器',
'cli.fileBrowserSelect': '选择',
'cli.fileBrowserCancel': '取消',
'cli.fileBrowserUp': '上级目录',
'cli.fileBrowserHome': '主目录',
'cli.fileBrowserShowHidden': '显示隐藏文件',
// CodexLens 配置
'codexlens.config': 'CodexLens 配置',

View File

@@ -554,6 +554,241 @@ function buildToolConfigModalContent(tool, config, models, status) {
'</div>';
}
// ========== File Browser Modal ==========
var fileBrowserState = {
currentPath: '',
showHidden: false,
onSelect: null
};
function showFileBrowserModal(onSelect) {
fileBrowserState.onSelect = onSelect;
fileBrowserState.showHidden = false;
// Create modal overlay
var overlay = document.createElement('div');
overlay.id = 'fileBrowserOverlay';
overlay.className = 'modal-overlay';
overlay.innerHTML = buildFileBrowserModalContent();
document.body.appendChild(overlay);
// Load initial directory (home)
loadFileBrowserDirectory('');
// Initialize events
initFileBrowserEvents();
// Initialize icons
if (window.lucide) lucide.createIcons();
}
function buildFileBrowserModalContent() {
return '<div class="modal-content file-browser-modal">' +
'<div class="modal-header">' +
'<h3><i data-lucide="folder-open" class="w-4 h-4"></i> ' + t('cli.fileBrowser') + '</h3>' +
'<button class="modal-close" id="fileBrowserCloseBtn">&times;</button>' +
'</div>' +
'<div class="modal-body">' +
'<div class="file-browser-toolbar">' +
'<button class="btn-sm btn-outline" id="fileBrowserUpBtn" title="' + t('cli.fileBrowserUp') + '">' +
'<i data-lucide="arrow-up" class="w-3.5 h-3.5"></i>' +
'</button>' +
'<button class="btn-sm btn-outline" id="fileBrowserHomeBtn" title="' + t('cli.fileBrowserHome') + '">' +
'<i data-lucide="home" class="w-3.5 h-3.5"></i>' +
'</button>' +
'<input type="text" id="fileBrowserPathInput" class="file-browser-path" placeholder="/" readonly />' +
'<label class="file-browser-hidden-toggle">' +
'<input type="checkbox" id="fileBrowserShowHidden" />' +
'<span>' + t('cli.fileBrowserShowHidden') + '</span>' +
'</label>' +
'</div>' +
'<div class="file-browser-list" id="fileBrowserList">' +
'<div class="file-browser-loading"><i data-lucide="loader-2" class="w-5 h-5 animate-spin"></i></div>' +
'</div>' +
'</div>' +
'<div class="modal-footer">' +
'<button class="btn btn-outline" id="fileBrowserCancelBtn">' + t('cli.fileBrowserCancel') + '</button>' +
'<button class="btn btn-primary" id="fileBrowserSelectBtn" disabled>' +
'<i data-lucide="check" class="w-3.5 h-3.5"></i> ' + t('cli.fileBrowserSelect') +
'</button>' +
'</div>' +
'</div>';
}
async function loadFileBrowserDirectory(path) {
var listContainer = document.getElementById('fileBrowserList');
var pathInput = document.getElementById('fileBrowserPathInput');
if (listContainer) {
listContainer.innerHTML = '<div class="file-browser-loading"><i data-lucide="loader-2" class="w-5 h-5 animate-spin"></i></div>';
if (window.lucide) lucide.createIcons();
}
try {
var response = await fetch('/api/dialog/browse', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: path, showHidden: fileBrowserState.showHidden })
});
if (!response.ok) {
throw new Error('Failed to load directory');
}
var data = await response.json();
fileBrowserState.currentPath = data.currentPath;
if (pathInput) {
pathInput.value = data.currentPath;
}
renderFileBrowserItems(data.items);
} catch (err) {
console.error('Failed to load directory:', err);
if (listContainer) {
listContainer.innerHTML = '<div class="file-browser-error">Failed to load directory</div>';
}
}
}
function renderFileBrowserItems(items) {
var listContainer = document.getElementById('fileBrowserList');
if (!listContainer) return;
if (!items || items.length === 0) {
listContainer.innerHTML = '<div class="file-browser-empty">Empty directory</div>';
return;
}
var html = items.map(function(item) {
var icon = item.isDirectory ? 'folder' : 'file';
var itemClass = 'file-browser-item' + (item.isDirectory ? ' is-directory' : ' is-file');
return '<div class="' + itemClass + '" data-path="' + escapeHtml(item.path) + '" data-is-dir="' + item.isDirectory + '">' +
'<i data-lucide="' + icon + '" class="w-4 h-4"></i>' +
'<span class="file-browser-item-name">' + escapeHtml(item.name) + '</span>' +
'</div>';
}).join('');
listContainer.innerHTML = html;
// Initialize icons
if (window.lucide) lucide.createIcons();
// Add click handlers
listContainer.querySelectorAll('.file-browser-item').forEach(function(el) {
el.onclick = function() {
var isDir = el.getAttribute('data-is-dir') === 'true';
var path = el.getAttribute('data-path');
if (isDir) {
// Navigate into directory
loadFileBrowserDirectory(path);
} else {
// Select file
listContainer.querySelectorAll('.file-browser-item').forEach(function(item) {
item.classList.remove('selected');
});
el.classList.add('selected');
// Enable select button
var selectBtn = document.getElementById('fileBrowserSelectBtn');
if (selectBtn) {
selectBtn.disabled = false;
selectBtn.setAttribute('data-selected-path', path);
}
}
};
// Double-click to select file or enter directory
el.ondblclick = function() {
var isDir = el.getAttribute('data-is-dir') === 'true';
var path = el.getAttribute('data-path');
if (isDir) {
loadFileBrowserDirectory(path);
} else {
// Select and close
closeFileBrowserModal(path);
}
};
});
}
function initFileBrowserEvents() {
// Close button
var closeBtn = document.getElementById('fileBrowserCloseBtn');
if (closeBtn) {
closeBtn.onclick = function() { closeFileBrowserModal(null); };
}
// Cancel button
var cancelBtn = document.getElementById('fileBrowserCancelBtn');
if (cancelBtn) {
cancelBtn.onclick = function() { closeFileBrowserModal(null); };
}
// Select button
var selectBtn = document.getElementById('fileBrowserSelectBtn');
if (selectBtn) {
selectBtn.onclick = function() {
var path = selectBtn.getAttribute('data-selected-path');
closeFileBrowserModal(path);
};
}
// Up button
var upBtn = document.getElementById('fileBrowserUpBtn');
if (upBtn) {
upBtn.onclick = function() {
// Get parent path
var currentPath = fileBrowserState.currentPath;
var parentPath = currentPath.replace(/[/\\][^/\\]+$/, '') || '/';
loadFileBrowserDirectory(parentPath);
};
}
// Home button
var homeBtn = document.getElementById('fileBrowserHomeBtn');
if (homeBtn) {
homeBtn.onclick = function() {
loadFileBrowserDirectory('');
};
}
// Show hidden checkbox
var showHiddenCheckbox = document.getElementById('fileBrowserShowHidden');
if (showHiddenCheckbox) {
showHiddenCheckbox.onchange = function() {
fileBrowserState.showHidden = showHiddenCheckbox.checked;
loadFileBrowserDirectory(fileBrowserState.currentPath);
};
}
// Click outside to close
var overlay = document.getElementById('fileBrowserOverlay');
if (overlay) {
overlay.onclick = function(e) {
if (e.target === overlay) {
closeFileBrowserModal(null);
}
};
}
}
function closeFileBrowserModal(selectedPath) {
var overlay = document.getElementById('fileBrowserOverlay');
if (overlay) {
overlay.remove();
}
if (fileBrowserState.onSelect && selectedPath) {
fileBrowserState.onSelect(selectedPath);
}
fileBrowserState.onSelect = null;
}
function initToolConfigModalEvents(tool, currentConfig, models) {
// Local tags state (copy from config)
var currentTags = (currentConfig.tags || []).slice();
@@ -754,38 +989,13 @@ function initToolConfigModalEvents(tool, currentConfig, models) {
// Environment file browse button (only for gemini/qwen)
var envFileBrowseBtn = document.getElementById('envFileBrowseBtn');
if (envFileBrowseBtn) {
envFileBrowseBtn.onclick = async function() {
try {
// Use file dialog API if available
var response = await fetch('/api/dialog/open-file', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: t('cli.envFile'),
filters: [
{ name: 'Environment Files', extensions: ['env'] },
{ name: 'All Files', extensions: ['*'] }
],
defaultPath: ''
})
});
if (response.ok) {
var data = await response.json();
if (data.filePath) {
var envFileInput = document.getElementById('envFileInput');
if (envFileInput) {
envFileInput.value = data.filePath;
}
}
} else {
// Fallback: prompt user to enter path manually
showRefreshToast('File dialog not available. Please enter path manually.', 'info');
envFileBrowseBtn.onclick = function() {
showFileBrowserModal(function(selectedPath) {
var envFileInput = document.getElementById('envFileInput');
if (envFileInput && selectedPath) {
envFileInput.value = selectedPath;
}
} catch (err) {
console.error('Failed to open file dialog:', err);
showRefreshToast('File dialog not available. Please enter path manually.', 'info');
}
});
};
}