mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-10 02:24:35 +08:00
feat(mcp): add read_file tool and simplify edit/write returns
- edit_file: truncate diff to 15 lines, compact result format - write_file: return only path/bytes/message - read_file: new tool with multi-file, directory, regex support - paths: single file, array, or directory - pattern: glob filter (*.ts) - contentPattern: regex content search - maxDepth, maxFiles, includeContent options - Update tool-strategy.md documentation 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -37,6 +37,19 @@ mcp__ccw-tools__write_file(path="file.txt", content="code with `backticks` and $
|
|||||||
|
|
||||||
**Options**: `backup`, `createDirectories`, `encoding`
|
**Options**: `backup`, `createDirectories`, `encoding`
|
||||||
|
|
||||||
|
### read_file
|
||||||
|
|
||||||
|
**When to Use**: Read multiple files, directory traversal, content search
|
||||||
|
|
||||||
|
```
|
||||||
|
mcp__ccw-tools__read_file(paths="file.ts") # Single file
|
||||||
|
mcp__ccw-tools__read_file(paths=["a.ts", "b.ts"]) # Multiple files
|
||||||
|
mcp__ccw-tools__read_file(paths="src/", pattern="*.ts") # Directory + glob
|
||||||
|
mcp__ccw-tools__read_file(paths="src/", contentPattern="TODO") # Regex search
|
||||||
|
```
|
||||||
|
|
||||||
|
**Options**: `pattern`, `contentPattern`, `maxDepth` (3), `includeContent` (true), `maxFiles` (50)
|
||||||
|
|
||||||
### codex_lens
|
### codex_lens
|
||||||
|
|
||||||
**When to Use**: Code indexing and semantic search
|
**When to Use**: Code indexing and semantic search
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import chalk from 'chalk';
|
import chalk from 'chalk';
|
||||||
|
import http from 'http';
|
||||||
import {
|
import {
|
||||||
cliExecutorTool,
|
cliExecutorTool,
|
||||||
getCliToolsStatus,
|
getCliToolsStatus,
|
||||||
@@ -12,6 +13,38 @@ import {
|
|||||||
getConversationDetail
|
getConversationDetail
|
||||||
} from '../tools/cli-executor.js';
|
} from '../tools/cli-executor.js';
|
||||||
|
|
||||||
|
// Dashboard notification settings
|
||||||
|
const DASHBOARD_PORT = process.env.CCW_PORT || 3456;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notify dashboard of CLI execution events (fire and forget)
|
||||||
|
*/
|
||||||
|
function notifyDashboard(data: Record<string, unknown>): void {
|
||||||
|
const payload = JSON.stringify({
|
||||||
|
type: 'cli_execution',
|
||||||
|
...data,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
});
|
||||||
|
|
||||||
|
const req = http.request({
|
||||||
|
hostname: 'localhost',
|
||||||
|
port: Number(DASHBOARD_PORT),
|
||||||
|
path: '/api/hook',
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Content-Length': Buffer.byteLength(payload)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fire and forget - log errors only in debug mode
|
||||||
|
req.on('error', (err) => {
|
||||||
|
if (process.env.DEBUG) console.error('[Dashboard] CLI notification failed:', err.message);
|
||||||
|
});
|
||||||
|
req.write(payload);
|
||||||
|
req.end();
|
||||||
|
}
|
||||||
|
|
||||||
interface CliExecOptions {
|
interface CliExecOptions {
|
||||||
tool?: string;
|
tool?: string;
|
||||||
mode?: string;
|
mode?: string;
|
||||||
@@ -88,6 +121,15 @@ async function execAction(prompt: string | undefined, options: CliExecOptions):
|
|||||||
console.log();
|
console.log();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Notify dashboard: execution started
|
||||||
|
notifyDashboard({
|
||||||
|
event: 'started',
|
||||||
|
tool,
|
||||||
|
mode,
|
||||||
|
prompt_preview: prompt.substring(0, 100) + (prompt.length > 100 ? '...' : ''),
|
||||||
|
custom_id: id || null
|
||||||
|
});
|
||||||
|
|
||||||
// Streaming output handler
|
// Streaming output handler
|
||||||
const onOutput = noStream ? null : (chunk: any) => {
|
const onOutput = noStream ? null : (chunk: any) => {
|
||||||
process.stdout.write(chunk.data);
|
process.stdout.write(chunk.data);
|
||||||
@@ -130,17 +172,49 @@ async function execAction(prompt: string | undefined, options: CliExecOptions):
|
|||||||
console.log(chalk.gray(` Total: ${result.conversation.turn_count} turns, ${(result.conversation.total_duration_ms / 1000).toFixed(1)}s`));
|
console.log(chalk.gray(` Total: ${result.conversation.turn_count} turns, ${(result.conversation.total_duration_ms / 1000).toFixed(1)}s`));
|
||||||
}
|
}
|
||||||
console.log(chalk.dim(` Continue: ccw cli exec "..." --resume ${result.execution.id}`));
|
console.log(chalk.dim(` Continue: ccw cli exec "..." --resume ${result.execution.id}`));
|
||||||
|
|
||||||
|
// Notify dashboard: execution completed
|
||||||
|
notifyDashboard({
|
||||||
|
event: 'completed',
|
||||||
|
tool,
|
||||||
|
mode,
|
||||||
|
execution_id: result.execution.id,
|
||||||
|
success: true,
|
||||||
|
duration_ms: result.execution.duration_ms,
|
||||||
|
turn_count: result.conversation.turn_count
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
console.log(chalk.red(` ✗ Failed (${result.execution.status})`));
|
console.log(chalk.red(` ✗ Failed (${result.execution.status})`));
|
||||||
console.log(chalk.gray(` ID: ${result.execution.id}`));
|
console.log(chalk.gray(` ID: ${result.execution.id}`));
|
||||||
if (result.stderr) {
|
if (result.stderr) {
|
||||||
console.error(chalk.red(result.stderr));
|
console.error(chalk.red(result.stderr));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Notify dashboard: execution failed
|
||||||
|
notifyDashboard({
|
||||||
|
event: 'completed',
|
||||||
|
tool,
|
||||||
|
mode,
|
||||||
|
execution_id: result.execution.id,
|
||||||
|
success: false,
|
||||||
|
status: result.execution.status,
|
||||||
|
duration_ms: result.execution.duration_ms
|
||||||
|
});
|
||||||
|
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const err = error as Error;
|
const err = error as Error;
|
||||||
console.error(chalk.red(` Error: ${err.message}`));
|
console.error(chalk.red(` Error: ${err.message}`));
|
||||||
|
|
||||||
|
// Notify dashboard: execution error
|
||||||
|
notifyDashboard({
|
||||||
|
event: 'error',
|
||||||
|
tool,
|
||||||
|
mode,
|
||||||
|
error: err.message
|
||||||
|
});
|
||||||
|
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { resolvePath, getRecentPaths, trackRecentPath, removeRecentPath, normali
|
|||||||
import { getCliToolsStatus, getExecutionHistory, getExecutionHistoryAsync, getExecutionDetail, getConversationDetail, deleteExecution, deleteExecutionAsync, batchDeleteExecutionsAsync, executeCliTool } from '../tools/cli-executor.js';
|
import { getCliToolsStatus, getExecutionHistory, getExecutionHistoryAsync, getExecutionDetail, getConversationDetail, deleteExecution, deleteExecutionAsync, batchDeleteExecutionsAsync, executeCliTool } from '../tools/cli-executor.js';
|
||||||
import { getAllManifests } from './manifest.js';
|
import { getAllManifests } from './manifest.js';
|
||||||
import { checkVenvStatus, bootstrapVenv, executeCodexLens, checkSemanticStatus, installSemantic } from '../tools/codex-lens.js';
|
import { checkVenvStatus, bootstrapVenv, executeCodexLens, checkSemanticStatus, installSemantic } from '../tools/codex-lens.js';
|
||||||
|
import { generateSmartContext, formatSmartContext } from '../tools/smart-context.js';
|
||||||
import { listTools } from '../tools/index.js';
|
import { listTools } from '../tools/index.js';
|
||||||
import type { ServerConfig } from '../types/config.js';interface ServerOptions { port?: number; initialPath?: string; host?: string; open?: boolean;}interface PostResult { error?: string; status?: number; [key: string]: unknown;}type PostHandler = (body: unknown) => Promise<PostResult>;
|
import type { ServerConfig } from '../types/config.js';interface ServerOptions { port?: number; initialPath?: string; host?: string; open?: boolean;}interface PostResult { error?: string; status?: number; [key: string]: unknown;}type PostHandler = (body: unknown) => Promise<PostResult>;
|
||||||
|
|
||||||
@@ -107,6 +108,7 @@ const MODULE_FILES = [
|
|||||||
'components/_review_tab.js',
|
'components/_review_tab.js',
|
||||||
'components/task-drawer-core.js',
|
'components/task-drawer-core.js',
|
||||||
'components/task-drawer-renderers.js',
|
'components/task-drawer-renderers.js',
|
||||||
|
'components/task-queue-sidebar.js',
|
||||||
'components/flowchart.js',
|
'components/flowchart.js',
|
||||||
'views/home.js',
|
'views/home.js',
|
||||||
'views/project-overview.js',
|
'views/project-overview.js',
|
||||||
@@ -635,38 +637,25 @@ export async function startServer(options: ServerOptions = {}): Promise<http.Ser
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// API: CLI Settings
|
|
||||||
if (pathname === '/api/cli/settings' && req.method === 'POST') {
|
|
||||||
handlePostRequest(req, res, async (body) => {
|
|
||||||
const { storageBackend: backend } = body as { storageBackend?: string };
|
|
||||||
|
|
||||||
if (backend && (backend === 'sqlite' || backend === 'json')) {
|
|
||||||
// Import and set storage backend dynamically
|
|
||||||
try {
|
|
||||||
const { setStorageBackend } = await import('../tools/cli-executor.js');
|
|
||||||
setStorageBackend(backend as 'sqlite' | 'json');
|
|
||||||
return { success: true, storageBackend: backend };
|
|
||||||
} catch (err) {
|
|
||||||
return { success: false, error: (err as Error).message };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { success: true, message: 'No changes' };
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// API: CLI Execution History
|
// API: CLI Execution History
|
||||||
if (pathname === '/api/cli/history') {
|
if (pathname === '/api/cli/history') {
|
||||||
const projectPath = url.searchParams.get('path') || initialPath;
|
const projectPath = url.searchParams.get('path') || initialPath;
|
||||||
const limit = parseInt(url.searchParams.get('limit') || '50', 10);
|
const limit = parseInt(url.searchParams.get('limit') || '50', 10);
|
||||||
const tool = url.searchParams.get('tool') || null;
|
const tool = url.searchParams.get('tool') || null;
|
||||||
const status = url.searchParams.get('status') || null;
|
const status = url.searchParams.get('status') || null;
|
||||||
|
const search = url.searchParams.get('search') || null;
|
||||||
const recursive = url.searchParams.get('recursive') !== 'false'; // Default true
|
const recursive = url.searchParams.get('recursive') !== 'false'; // Default true
|
||||||
|
|
||||||
const history = getExecutionHistory(projectPath, { limit, tool, status, recursive });
|
// Use async version to ensure SQLite is initialized
|
||||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
getExecutionHistoryAsync(projectPath, { limit, tool, status, search, recursive })
|
||||||
res.end(JSON.stringify(history));
|
.then(history => {
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify(history));
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ error: err.message }));
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -683,14 +672,21 @@ export async function startServer(options: ServerOptions = {}): Promise<http.Ser
|
|||||||
|
|
||||||
// Handle DELETE request
|
// Handle DELETE request
|
||||||
if (req.method === 'DELETE') {
|
if (req.method === 'DELETE') {
|
||||||
const result = deleteExecution(projectPath, executionId);
|
// Use async version to ensure SQLite is initialized
|
||||||
if (result.success) {
|
deleteExecutionAsync(projectPath, executionId)
|
||||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
.then(result => {
|
||||||
res.end(JSON.stringify({ success: true, message: 'Execution deleted' }));
|
if (result.success) {
|
||||||
} else {
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
res.end(JSON.stringify({ success: true, message: 'Execution deleted' }));
|
||||||
res.end(JSON.stringify({ error: result.error || 'Delete failed' }));
|
} else {
|
||||||
}
|
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ error: result.error || 'Delete failed' }));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ error: err.message }));
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -725,12 +721,32 @@ export async function startServer(options: ServerOptions = {}): Promise<http.Ser
|
|||||||
// API: Execute CLI Tool
|
// API: Execute CLI Tool
|
||||||
if (pathname === '/api/cli/execute' && req.method === 'POST') {
|
if (pathname === '/api/cli/execute' && req.method === 'POST') {
|
||||||
handlePostRequest(req, res, async (body) => {
|
handlePostRequest(req, res, async (body) => {
|
||||||
const { tool, prompt, mode, model, dir, includeDirs, timeout } = body;
|
const { tool, prompt, mode, format, model, dir, includeDirs, timeout, smartContext } = body;
|
||||||
|
|
||||||
if (!tool || !prompt) {
|
if (!tool || !prompt) {
|
||||||
return { error: 'tool and prompt are required', status: 400 };
|
return { error: 'tool and prompt are required', status: 400 };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Generate smart context if enabled
|
||||||
|
let finalPrompt = prompt;
|
||||||
|
if (smartContext?.enabled) {
|
||||||
|
try {
|
||||||
|
const contextResult = await generateSmartContext(prompt, {
|
||||||
|
enabled: true,
|
||||||
|
maxFiles: smartContext.maxFiles || 10,
|
||||||
|
searchMode: 'text'
|
||||||
|
}, dir || initialPath);
|
||||||
|
|
||||||
|
const contextAppendage = formatSmartContext(contextResult);
|
||||||
|
if (contextAppendage) {
|
||||||
|
finalPrompt = prompt + contextAppendage;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[Smart Context] Failed to generate:', err);
|
||||||
|
// Continue without smart context
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Start execution
|
// Start execution
|
||||||
const executionId = `${Date.now()}-${tool}`;
|
const executionId = `${Date.now()}-${tool}`;
|
||||||
|
|
||||||
@@ -749,10 +765,11 @@ export async function startServer(options: ServerOptions = {}): Promise<http.Ser
|
|||||||
// Execute with streaming output broadcast
|
// Execute with streaming output broadcast
|
||||||
const result = await executeCliTool({
|
const result = await executeCliTool({
|
||||||
tool,
|
tool,
|
||||||
prompt,
|
prompt: finalPrompt,
|
||||||
mode: mode || 'analysis',
|
mode: mode || 'analysis',
|
||||||
|
format: format || 'plain',
|
||||||
model,
|
model,
|
||||||
dir: dir || initialPath,
|
cd: dir || initialPath,
|
||||||
includeDirs,
|
includeDirs,
|
||||||
timeout: timeout || 300000,
|
timeout: timeout || 300000,
|
||||||
stream: true
|
stream: true
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ const SERVER_NAME = 'ccw-tools';
|
|||||||
const SERVER_VERSION = '6.1.4';
|
const SERVER_VERSION = '6.1.4';
|
||||||
|
|
||||||
// Default enabled tools (core set)
|
// Default enabled tools (core set)
|
||||||
const DEFAULT_TOOLS: string[] = ['write_file', 'edit_file', 'codex_lens', 'smart_search'];
|
const DEFAULT_TOOLS: string[] = ['write_file', 'edit_file', 'read_file', 'codex_lens', 'smart_search'];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get list of enabled tools from environment or defaults
|
* Get list of enabled tools from environment or defaults
|
||||||
|
|||||||
@@ -536,16 +536,16 @@
|
|||||||
position: fixed;
|
position: fixed;
|
||||||
top: 70px;
|
top: 70px;
|
||||||
right: 20px;
|
right: 20px;
|
||||||
max-width: 360px;
|
max-width: 280px;
|
||||||
padding: 12px 16px;
|
padding: 8px 12px;
|
||||||
background: hsl(var(--card));
|
background: hsl(var(--card));
|
||||||
border: 1px solid hsl(var(--border));
|
border: 1px solid hsl(var(--border));
|
||||||
border-radius: 8px;
|
border-radius: 6px;
|
||||||
box-shadow: 0 8px 24px rgb(0 0 0 / 0.15);
|
box-shadow: 0 4px 12px rgb(0 0 0 / 0.12);
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 6px;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateX(100%);
|
transform: translateX(100%);
|
||||||
transition: all 0.3s ease-out;
|
transition: all 0.3s ease-out;
|
||||||
@@ -577,11 +577,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.notification-icon {
|
.notification-icon {
|
||||||
font-size: 1.25rem;
|
font-size: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.notification-message {
|
.notification-message {
|
||||||
font-size: 0.875rem;
|
font-size: 0.8125rem;
|
||||||
color: hsl(var(--foreground));
|
color: hsl(var(--foreground));
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
@@ -938,26 +938,29 @@
|
|||||||
NOTIFICATION SIDEBAR (Right-Side Toolbar)
|
NOTIFICATION SIDEBAR (Right-Side Toolbar)
|
||||||
========================================== */
|
========================================== */
|
||||||
|
|
||||||
/* Sidebar Container */
|
/* Sidebar Container - Compact floating panel */
|
||||||
.notif-sidebar {
|
.notif-sidebar {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0;
|
top: 60px;
|
||||||
right: 0;
|
right: 16px;
|
||||||
width: 380px;
|
width: 320px;
|
||||||
max-width: 90vw;
|
max-width: 90vw;
|
||||||
height: 100vh;
|
max-height: 70vh;
|
||||||
background: hsl(var(--card));
|
background: hsl(var(--card));
|
||||||
border-left: 1px solid hsl(var(--border));
|
border: 1px solid hsl(var(--border));
|
||||||
box-shadow: -4px 0 24px rgb(0 0 0 / 0.15);
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 8px 24px rgb(0 0 0 / 0.15);
|
||||||
z-index: 1100;
|
z-index: 1100;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
transform: translateX(100%);
|
transform: translateX(calc(100% + 20px));
|
||||||
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
opacity: 0;
|
||||||
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.notif-sidebar.open {
|
.notif-sidebar.open {
|
||||||
transform: translateX(0);
|
transform: translateX(0);
|
||||||
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Sidebar Header */
|
/* Sidebar Header */
|
||||||
@@ -965,35 +968,36 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 16px 20px;
|
padding: 12px 14px;
|
||||||
border-bottom: 1px solid hsl(var(--border));
|
border-bottom: 1px solid hsl(var(--border));
|
||||||
background: hsl(var(--card));
|
background: hsl(var(--muted) / 0.3);
|
||||||
|
border-radius: 8px 8px 0 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.notif-sidebar-title {
|
.notif-sidebar-title {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 8px;
|
||||||
font-size: 1rem;
|
font-size: 0.875rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: hsl(var(--foreground));
|
color: hsl(var(--foreground));
|
||||||
}
|
}
|
||||||
|
|
||||||
.notif-title-icon {
|
.notif-title-icon {
|
||||||
font-size: 1.25rem;
|
font-size: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.notif-count-badge {
|
.notif-count-badge {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
min-width: 22px;
|
min-width: 18px;
|
||||||
height: 22px;
|
height: 18px;
|
||||||
padding: 0 6px;
|
padding: 0 5px;
|
||||||
background: hsl(var(--primary));
|
background: hsl(var(--primary));
|
||||||
color: hsl(var(--primary-foreground));
|
color: hsl(var(--primary-foreground));
|
||||||
border-radius: 11px;
|
border-radius: 9px;
|
||||||
font-size: 0.75rem;
|
font-size: 0.6875rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1001,11 +1005,11 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 32px;
|
width: 26px;
|
||||||
height: 32px;
|
height: 26px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 6px;
|
border-radius: 4px;
|
||||||
color: hsl(var(--muted-foreground));
|
color: hsl(var(--muted-foreground));
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.15s;
|
transition: all 0.15s;
|
||||||
@@ -1019,21 +1023,21 @@
|
|||||||
/* Sidebar Actions */
|
/* Sidebar Actions */
|
||||||
.notif-sidebar-actions {
|
.notif-sidebar-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 6px;
|
||||||
padding: 12px 20px;
|
padding: 8px 14px;
|
||||||
border-bottom: 1px solid hsl(var(--border));
|
border-bottom: 1px solid hsl(var(--border));
|
||||||
background: hsl(var(--muted) / 0.3);
|
background: hsl(var(--background));
|
||||||
}
|
}
|
||||||
|
|
||||||
.notif-action-btn {
|
.notif-action-btn {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 4px;
|
||||||
padding: 6px 12px;
|
padding: 4px 10px;
|
||||||
background: hsl(var(--card));
|
background: hsl(var(--card));
|
||||||
border: 1px solid hsl(var(--border));
|
border: 1px solid hsl(var(--border));
|
||||||
border-radius: 6px;
|
border-radius: 4px;
|
||||||
font-size: 0.8125rem;
|
font-size: 0.75rem;
|
||||||
color: hsl(var(--muted-foreground));
|
color: hsl(var(--muted-foreground));
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.15s;
|
transition: all 0.15s;
|
||||||
@@ -1049,7 +1053,7 @@
|
|||||||
.notif-sidebar-content {
|
.notif-sidebar-content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 12px;
|
padding: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Empty State */
|
/* Empty State */
|
||||||
@@ -1058,36 +1062,74 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 48px 24px;
|
padding: 32px 16px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.notif-empty-icon {
|
.notif-empty-icon {
|
||||||
font-size: 3rem;
|
font-size: 2rem;
|
||||||
opacity: 0.3;
|
opacity: 0.3;
|
||||||
margin-bottom: 16px;
|
margin-bottom: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.notif-empty-text {
|
.notif-empty-text {
|
||||||
font-size: 1rem;
|
font-size: 0.875rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: hsl(var(--muted-foreground));
|
color: hsl(var(--muted-foreground));
|
||||||
margin-bottom: 8px;
|
margin-bottom: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.notif-empty-hint {
|
.notif-empty-hint {
|
||||||
font-size: 0.8125rem;
|
font-size: 0.75rem;
|
||||||
color: hsl(var(--muted-foreground) / 0.7);
|
color: hsl(var(--muted-foreground) / 0.7);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Notification Settings */
|
||||||
|
.notif-sidebar-settings {
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-bottom: 1px solid hsl(var(--border));
|
||||||
|
background: hsl(var(--muted) / 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notif-setting-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.notif-setting-item input[type="checkbox"] {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
accent-color: hsl(var(--primary));
|
||||||
|
}
|
||||||
|
|
||||||
|
.notif-setting-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notif-setting-label svg {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
/* Notification Items */
|
/* Notification Items */
|
||||||
.notif-item {
|
.notif-item {
|
||||||
padding: 12px 14px;
|
padding: 8px 10px;
|
||||||
background: hsl(var(--card));
|
background: hsl(var(--card));
|
||||||
border: 1px solid hsl(var(--border));
|
border: 1px solid hsl(var(--border));
|
||||||
border-radius: 8px;
|
border-radius: 6px;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 6px;
|
||||||
transition: all 0.15s;
|
transition: all 0.15s;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notif-item.has-details {
|
||||||
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.notif-item:hover {
|
.notif-item:hover {
|
||||||
@@ -1118,11 +1160,11 @@
|
|||||||
.notif-item-header {
|
.notif-item-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
gap: 10px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.notif-icon {
|
.notif-icon {
|
||||||
font-size: 1.125rem;
|
font-size: 0.9375rem;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1133,33 +1175,61 @@
|
|||||||
|
|
||||||
.notif-message {
|
.notif-message {
|
||||||
display: block;
|
display: block;
|
||||||
font-size: 0.875rem;
|
font-size: 0.8125rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: hsl(var(--foreground));
|
color: hsl(var(--foreground));
|
||||||
line-height: 1.4;
|
line-height: 1.3;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
.notif-source {
|
.notif-source {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
margin-top: 4px;
|
margin-top: 3px;
|
||||||
padding: 2px 8px;
|
padding: 1px 6px;
|
||||||
background: hsl(var(--muted));
|
background: hsl(var(--muted));
|
||||||
border-radius: 4px;
|
border-radius: 3px;
|
||||||
font-size: 0.6875rem;
|
font-size: 0.625rem;
|
||||||
color: hsl(var(--muted-foreground));
|
color: hsl(var(--muted-foreground));
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Expand Icon */
|
||||||
|
.notif-expand-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 0.625rem;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
opacity: 0.6;
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notif-item.expanded .notif-expand-icon {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Details Hint (collapsed state) */
|
||||||
|
.notif-details-hint {
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
opacity: 0.7;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Expanded Details */
|
||||||
|
.notif-details-expanded {
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
/* Notification Details */
|
/* Notification Details */
|
||||||
.notif-details {
|
.notif-details {
|
||||||
margin-top: 10px;
|
margin-top: 6px;
|
||||||
padding: 10px 12px;
|
padding: 6px 8px;
|
||||||
background: hsl(var(--muted) / 0.5);
|
background: hsl(var(--muted) / 0.5);
|
||||||
border-radius: 6px;
|
border-radius: 4px;
|
||||||
font-size: 0.8125rem;
|
font-size: 0.75rem;
|
||||||
color: hsl(var(--muted-foreground));
|
color: hsl(var(--muted-foreground));
|
||||||
line-height: 1.5;
|
line-height: 1.4;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
@@ -1167,12 +1237,12 @@
|
|||||||
|
|
||||||
/* JSON Formatted Details */
|
/* JSON Formatted Details */
|
||||||
.notif-details-json {
|
.notif-details-json {
|
||||||
margin-top: 10px;
|
margin-top: 6px;
|
||||||
padding: 10px 12px;
|
padding: 6px 8px;
|
||||||
background: hsl(var(--muted) / 0.5);
|
background: hsl(var(--muted) / 0.5);
|
||||||
border-radius: 6px;
|
border-radius: 4px;
|
||||||
font-size: 0.8125rem;
|
font-size: 0.75rem;
|
||||||
line-height: 1.6;
|
line-height: 1.4;
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1233,13 +1303,13 @@
|
|||||||
|
|
||||||
/* Notification Meta */
|
/* Notification Meta */
|
||||||
.notif-meta {
|
.notif-meta {
|
||||||
margin-top: 8px;
|
margin-top: 6px;
|
||||||
padding-top: 8px;
|
padding-top: 6px;
|
||||||
border-top: 1px solid hsl(var(--border) / 0.5);
|
border-top: 1px solid hsl(var(--border) / 0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
.notif-time {
|
.notif-time {
|
||||||
font-size: 0.75rem;
|
font-size: 0.6875rem;
|
||||||
color: hsl(var(--muted-foreground));
|
color: hsl(var(--muted-foreground));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1318,20 +1388,21 @@
|
|||||||
/* Toast Notification */
|
/* Toast Notification */
|
||||||
.notif-toast {
|
.notif-toast {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
bottom: 24px;
|
bottom: 20px;
|
||||||
right: 24px;
|
right: 20px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 8px;
|
||||||
padding: 12px 18px;
|
padding: 8px 14px;
|
||||||
background: hsl(var(--card));
|
background: hsl(var(--card));
|
||||||
border: 1px solid hsl(var(--border));
|
border: 1px solid hsl(var(--border));
|
||||||
border-radius: 8px;
|
border-radius: 6px;
|
||||||
box-shadow: 0 8px 24px rgb(0 0 0 / 0.2);
|
box-shadow: 0 4px 12px rgb(0 0 0 / 0.15);
|
||||||
z-index: 1200;
|
z-index: 1200;
|
||||||
transform: translateY(100px);
|
transform: translateY(100px);
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
max-width: 280px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.notif-toast.show {
|
.notif-toast.show {
|
||||||
@@ -1356,13 +1427,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.toast-icon {
|
.toast-icon {
|
||||||
font-size: 1.25rem;
|
font-size: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toast-message {
|
.toast-message {
|
||||||
font-size: 0.875rem;
|
font-size: 0.8125rem;
|
||||||
color: hsl(var(--foreground));
|
color: hsl(var(--foreground));
|
||||||
max-width: 280px;
|
max-width: 220px;
|
||||||
|
line-height: 1.3;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Responsive */
|
/* Responsive */
|
||||||
@@ -1397,26 +1469,29 @@
|
|||||||
TASK QUEUE SIDEBAR (Right-Side Toolbar)
|
TASK QUEUE SIDEBAR (Right-Side Toolbar)
|
||||||
========================================== */
|
========================================== */
|
||||||
|
|
||||||
/* Sidebar Container */
|
/* Sidebar Container - Compact floating panel (matches notif-sidebar) */
|
||||||
.task-queue-sidebar {
|
.task-queue-sidebar {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0;
|
top: 60px;
|
||||||
right: 0;
|
right: 16px;
|
||||||
width: 400px;
|
width: 360px;
|
||||||
max-width: 90vw;
|
max-width: 90vw;
|
||||||
height: 100vh;
|
max-height: 70vh;
|
||||||
background: hsl(var(--card));
|
background: hsl(var(--card));
|
||||||
border-left: 1px solid hsl(var(--border));
|
border: 1px solid hsl(var(--border));
|
||||||
box-shadow: -4px 0 24px rgb(0 0 0 / 0.15);
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 8px 24px rgb(0 0 0 / 0.15);
|
||||||
z-index: 1100;
|
z-index: 1100;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
transform: translateX(100%);
|
transform: translateX(calc(100% + 20px));
|
||||||
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
opacity: 0;
|
||||||
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.task-queue-sidebar.open {
|
.task-queue-sidebar.open {
|
||||||
transform: translateX(0);
|
transform: translateX(0);
|
||||||
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Sidebar Header */
|
/* Sidebar Header */
|
||||||
@@ -1424,9 +1499,10 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 16px 20px;
|
padding: 12px 14px;
|
||||||
border-bottom: 1px solid hsl(var(--border));
|
border-bottom: 1px solid hsl(var(--border));
|
||||||
background: hsl(var(--card));
|
background: hsl(var(--muted) / 0.3);
|
||||||
|
border-radius: 8px 8px 0 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.task-queue-title {
|
.task-queue-title {
|
||||||
|
|||||||
@@ -358,15 +358,20 @@ async function refreshCliHistory() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ========== Delete Execution ==========
|
// ========== Delete Execution ==========
|
||||||
function confirmDeleteExecution(executionId) {
|
function confirmDeleteExecution(executionId, sourceDir) {
|
||||||
if (confirm('Delete this execution record? This action cannot be undone.')) {
|
if (confirm('Delete this execution record? This action cannot be undone.')) {
|
||||||
deleteExecution(executionId);
|
deleteExecution(executionId, sourceDir);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteExecution(executionId) {
|
async function deleteExecution(executionId, sourceDir) {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/cli/execution?path=${encodeURIComponent(projectPath)}&id=${encodeURIComponent(executionId)}`, {
|
// Build correct path - use sourceDir if provided for recursive items
|
||||||
|
const basePath = sourceDir && sourceDir !== '.'
|
||||||
|
? projectPath + '/' + sourceDir
|
||||||
|
: projectPath;
|
||||||
|
|
||||||
|
const response = await fetch(`/api/cli/execution?path=${encodeURIComponent(basePath)}&id=${encodeURIComponent(executionId)}`, {
|
||||||
method: 'DELETE'
|
method: 'DELETE'
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -375,9 +380,15 @@ async function deleteExecution(executionId) {
|
|||||||
throw new Error(error.error || 'Failed to delete');
|
throw new Error(error.error || 'Failed to delete');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove from local state
|
// Reload fresh data from server and re-render
|
||||||
cliExecutionHistory = cliExecutionHistory.filter(exec => exec.id !== executionId);
|
await loadCliHistory();
|
||||||
renderCliHistory();
|
|
||||||
|
// Render appropriate view based on current view
|
||||||
|
if (typeof currentView !== 'undefined' && (currentView === 'history' || currentView === 'cli-history')) {
|
||||||
|
renderCliHistoryView();
|
||||||
|
} else {
|
||||||
|
renderCliHistory();
|
||||||
|
}
|
||||||
showRefreshToast('Execution deleted', 'success');
|
showRefreshToast('Execution deleted', 'success');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to delete execution:', err);
|
console.error('Failed to delete execution:', err);
|
||||||
|
|||||||
@@ -2,11 +2,26 @@
|
|||||||
// GLOBAL NOTIFICATION SYSTEM - Right Sidebar
|
// GLOBAL NOTIFICATION SYSTEM - Right Sidebar
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// Right-side slide-out toolbar for notifications and quick actions
|
// Right-side slide-out toolbar for notifications and quick actions
|
||||||
|
// Supports browser system notifications (cross-platform)
|
||||||
|
|
||||||
|
// Notification settings
|
||||||
|
let notifSettings = {
|
||||||
|
systemNotifEnabled: false,
|
||||||
|
soundEnabled: false
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize global notification sidebar
|
* Initialize global notification sidebar
|
||||||
*/
|
*/
|
||||||
function initGlobalNotifications() {
|
function initGlobalNotifications() {
|
||||||
|
// Load settings from localStorage
|
||||||
|
loadNotifSettings();
|
||||||
|
|
||||||
|
// Request notification permission if enabled
|
||||||
|
if (notifSettings.systemNotifEnabled) {
|
||||||
|
requestNotificationPermission();
|
||||||
|
}
|
||||||
|
|
||||||
// Create sidebar if not exists
|
// Create sidebar if not exists
|
||||||
if (!document.getElementById('notifSidebar')) {
|
if (!document.getElementById('notifSidebar')) {
|
||||||
const sidebarHtml = `
|
const sidebarHtml = `
|
||||||
@@ -24,6 +39,19 @@ function initGlobalNotifications() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="notif-sidebar-settings" id="notifSettings">
|
||||||
|
<label class="notif-setting-item">
|
||||||
|
<input type="checkbox" id="systemNotifToggle" onchange="toggleSystemNotifications(this.checked)">
|
||||||
|
<span class="notif-setting-label">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/>
|
||||||
|
<path d="M13.73 21a2 2 0 0 1-3.46 0"/>
|
||||||
|
</svg>
|
||||||
|
System Notifications
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="notif-sidebar-actions">
|
<div class="notif-sidebar-actions">
|
||||||
<button class="notif-action-btn" onclick="markAllNotificationsRead()" title="Mark all read">
|
<button class="notif-action-btn" onclick="markAllNotificationsRead()" title="Mark all read">
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
@@ -60,12 +88,132 @@ function initGlobalNotifications() {
|
|||||||
container.id = 'notifSidebarContainer';
|
container.id = 'notifSidebarContainer';
|
||||||
container.innerHTML = sidebarHtml;
|
container.innerHTML = sidebarHtml;
|
||||||
document.body.appendChild(container);
|
document.body.appendChild(container);
|
||||||
|
|
||||||
|
// Initialize toggle state
|
||||||
|
const toggle = document.getElementById('systemNotifToggle');
|
||||||
|
if (toggle) {
|
||||||
|
toggle.checked = notifSettings.systemNotifEnabled;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
renderGlobalNotifications();
|
renderGlobalNotifications();
|
||||||
updateGlobalNotifBadge();
|
updateGlobalNotifBadge();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load notification settings from localStorage
|
||||||
|
*/
|
||||||
|
function loadNotifSettings() {
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem('ccw_notif_settings');
|
||||||
|
if (saved) {
|
||||||
|
notifSettings = { ...notifSettings, ...JSON.parse(saved) };
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[Notif] Failed to load settings:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save notification settings to localStorage
|
||||||
|
*/
|
||||||
|
function saveNotifSettings() {
|
||||||
|
try {
|
||||||
|
localStorage.setItem('ccw_notif_settings', JSON.stringify(notifSettings));
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[Notif] Failed to save settings:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle system notifications
|
||||||
|
*/
|
||||||
|
function toggleSystemNotifications(enabled) {
|
||||||
|
notifSettings.systemNotifEnabled = enabled;
|
||||||
|
saveNotifSettings();
|
||||||
|
|
||||||
|
if (enabled) {
|
||||||
|
requestNotificationPermission();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request browser notification permission
|
||||||
|
*/
|
||||||
|
async function requestNotificationPermission() {
|
||||||
|
if (!('Notification' in window)) {
|
||||||
|
console.warn('[Notif] Browser does not support notifications');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Notification.permission === 'granted') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Notification.permission !== 'denied') {
|
||||||
|
const permission = await Notification.requestPermission();
|
||||||
|
return permission === 'granted';
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show system notification (browser notification)
|
||||||
|
*/
|
||||||
|
function showSystemNotification(notification) {
|
||||||
|
if (!notifSettings.systemNotifEnabled) return;
|
||||||
|
if (!('Notification' in window)) return;
|
||||||
|
if (Notification.permission !== 'granted') return;
|
||||||
|
|
||||||
|
const typeIcon = {
|
||||||
|
'info': 'ℹ️',
|
||||||
|
'success': '✅',
|
||||||
|
'warning': '⚠️',
|
||||||
|
'error': '❌'
|
||||||
|
}[notification.type] || '🔔';
|
||||||
|
|
||||||
|
const title = `${typeIcon} ${notification.message}`;
|
||||||
|
let body = '';
|
||||||
|
|
||||||
|
if (notification.source) {
|
||||||
|
body = `[${notification.source}]`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract plain text from details if HTML formatted
|
||||||
|
if (notification.details) {
|
||||||
|
const detailText = notification.details.replace(/<[^>]*>/g, '').trim();
|
||||||
|
if (detailText) {
|
||||||
|
body += body ? '\n' + detailText : detailText;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const sysNotif = new Notification(title, {
|
||||||
|
body: body.substring(0, 200),
|
||||||
|
icon: '/favicon.ico',
|
||||||
|
tag: `ccw-notif-${notification.id}`,
|
||||||
|
requireInteraction: notification.type === 'error'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Click to open sidebar
|
||||||
|
sysNotif.onclick = () => {
|
||||||
|
window.focus();
|
||||||
|
if (!isNotificationPanelVisible) {
|
||||||
|
toggleNotifSidebar();
|
||||||
|
}
|
||||||
|
sysNotif.close();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Auto close after 5s (except errors)
|
||||||
|
if (notification.type !== 'error') {
|
||||||
|
setTimeout(() => sysNotif.close(), 5000);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[Notif] Failed to show system notification:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Toggle notification sidebar visibility
|
* Toggle notification sidebar visibility
|
||||||
*/
|
*/
|
||||||
@@ -80,8 +228,6 @@ function toggleNotifSidebar() {
|
|||||||
sidebar.classList.add('open');
|
sidebar.classList.add('open');
|
||||||
overlay.classList.add('show');
|
overlay.classList.add('show');
|
||||||
toggle.classList.add('hidden');
|
toggle.classList.add('hidden');
|
||||||
// Mark notifications as read when opened
|
|
||||||
markAllNotificationsRead();
|
|
||||||
} else {
|
} else {
|
||||||
sidebar.classList.remove('open');
|
sidebar.classList.remove('open');
|
||||||
overlay.classList.remove('show');
|
overlay.classList.remove('show');
|
||||||
@@ -105,8 +251,11 @@ function toggleGlobalNotifications() {
|
|||||||
function addGlobalNotification(type, message, details = null, source = null) {
|
function addGlobalNotification(type, message, details = null, source = null) {
|
||||||
// Format details if it's an object
|
// Format details if it's an object
|
||||||
let formattedDetails = details;
|
let formattedDetails = details;
|
||||||
|
let rawDetails = details; // Keep raw for system notification
|
||||||
|
|
||||||
if (details && typeof details === 'object') {
|
if (details && typeof details === 'object') {
|
||||||
formattedDetails = formatNotificationJson(details);
|
formattedDetails = formatNotificationJson(details);
|
||||||
|
rawDetails = JSON.stringify(details, null, 2);
|
||||||
} else if (typeof details === 'string') {
|
} else if (typeof details === 'string') {
|
||||||
// Try to parse and format if it looks like JSON
|
// Try to parse and format if it looks like JSON
|
||||||
const trimmed = details.trim();
|
const trimmed = details.trim();
|
||||||
@@ -115,6 +264,7 @@ function addGlobalNotification(type, message, details = null, source = null) {
|
|||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(trimmed);
|
const parsed = JSON.parse(trimmed);
|
||||||
formattedDetails = formatNotificationJson(parsed);
|
formattedDetails = formatNotificationJson(parsed);
|
||||||
|
rawDetails = JSON.stringify(parsed, null, 2);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Not valid JSON, use as-is
|
// Not valid JSON, use as-is
|
||||||
formattedDetails = details;
|
formattedDetails = details;
|
||||||
@@ -127,9 +277,11 @@ function addGlobalNotification(type, message, details = null, source = null) {
|
|||||||
type,
|
type,
|
||||||
message,
|
message,
|
||||||
details: formattedDetails,
|
details: formattedDetails,
|
||||||
|
rawDetails: rawDetails,
|
||||||
source,
|
source,
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
read: false
|
read: false,
|
||||||
|
expanded: false
|
||||||
};
|
};
|
||||||
|
|
||||||
globalNotificationQueue.unshift(notification);
|
globalNotificationQueue.unshift(notification);
|
||||||
@@ -147,10 +299,8 @@ function addGlobalNotification(type, message, details = null, source = null) {
|
|||||||
renderGlobalNotifications();
|
renderGlobalNotifications();
|
||||||
updateGlobalNotifBadge();
|
updateGlobalNotifBadge();
|
||||||
|
|
||||||
// Show toast for important notifications
|
// Show system notification instead of toast
|
||||||
if (type === 'error' || type === 'success') {
|
showSystemNotification(notification);
|
||||||
showNotificationToast(notification);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -217,36 +367,14 @@ function formatNotificationJson(obj) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Show a brief toast notification
|
* Toggle notification item expansion
|
||||||
*/
|
*/
|
||||||
function showNotificationToast(notification) {
|
function toggleNotifExpand(notifId) {
|
||||||
const typeIcon = {
|
const notif = globalNotificationQueue.find(n => n.id === notifId);
|
||||||
'info': 'ℹ️',
|
if (notif) {
|
||||||
'success': '✅',
|
notif.expanded = !notif.expanded;
|
||||||
'warning': '⚠️',
|
renderGlobalNotifications();
|
||||||
'error': '❌'
|
}
|
||||||
}[notification.type] || 'ℹ️';
|
|
||||||
|
|
||||||
// Remove existing toast
|
|
||||||
const existing = document.querySelector('.notif-toast');
|
|
||||||
if (existing) existing.remove();
|
|
||||||
|
|
||||||
const toast = document.createElement('div');
|
|
||||||
toast.className = `notif-toast type-${notification.type}`;
|
|
||||||
toast.innerHTML = `
|
|
||||||
<span class="toast-icon">${typeIcon}</span>
|
|
||||||
<span class="toast-message">${escapeHtml(notification.message)}</span>
|
|
||||||
`;
|
|
||||||
document.body.appendChild(toast);
|
|
||||||
|
|
||||||
// Animate in
|
|
||||||
requestAnimationFrame(() => toast.classList.add('show'));
|
|
||||||
|
|
||||||
// Auto-remove
|
|
||||||
setTimeout(() => {
|
|
||||||
toast.classList.remove('show');
|
|
||||||
setTimeout(() => toast.remove(), 300);
|
|
||||||
}, 3000);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -277,26 +405,36 @@ function renderGlobalNotifications() {
|
|||||||
|
|
||||||
const time = formatNotifTime(notif.timestamp);
|
const time = formatNotifTime(notif.timestamp);
|
||||||
const sourceLabel = notif.source ? `<span class="notif-source">${escapeHtml(notif.source)}</span>` : '';
|
const sourceLabel = notif.source ? `<span class="notif-source">${escapeHtml(notif.source)}</span>` : '';
|
||||||
|
const hasDetails = notif.details && notif.details.length > 0;
|
||||||
|
const expandIcon = hasDetails ? (notif.expanded ? '▼' : '▶') : '';
|
||||||
|
|
||||||
// Details may already be HTML formatted or plain text
|
// Details section - collapsed by default, show preview
|
||||||
let detailsHtml = '';
|
let detailsHtml = '';
|
||||||
if (notif.details) {
|
if (hasDetails) {
|
||||||
// Check if details is already HTML formatted (contains our json-* classes)
|
if (notif.expanded) {
|
||||||
if (typeof notif.details === 'string' && notif.details.includes('class="json-')) {
|
// Expanded view - show full details
|
||||||
detailsHtml = `<div class="notif-details-json">${notif.details}</div>`;
|
if (typeof notif.details === 'string' && notif.details.includes('class="json-')) {
|
||||||
|
detailsHtml = `<div class="notif-details-json notif-details-expanded">${notif.details}</div>`;
|
||||||
|
} else {
|
||||||
|
detailsHtml = `<div class="notif-details notif-details-expanded">${escapeHtml(String(notif.details))}</div>`;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
detailsHtml = `<div class="notif-details">${escapeHtml(String(notif.details))}</div>`;
|
// Collapsed view - show hint
|
||||||
|
detailsHtml = `<div class="notif-details-hint">Click to view details</div>`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="notif-item type-${notif.type} ${notif.read ? 'read' : ''}" data-id="${notif.id}">
|
<div class="notif-item type-${notif.type} ${notif.read ? 'read' : ''} ${hasDetails ? 'has-details' : ''} ${notif.expanded ? 'expanded' : ''}"
|
||||||
|
data-id="${notif.id}"
|
||||||
|
onclick="toggleNotifExpand(${notif.id})">
|
||||||
<div class="notif-item-header">
|
<div class="notif-item-header">
|
||||||
<span class="notif-icon">${typeIcon}</span>
|
<span class="notif-icon">${typeIcon}</span>
|
||||||
<div class="notif-item-content">
|
<div class="notif-item-content">
|
||||||
<span class="notif-message">${escapeHtml(notif.message)}</span>
|
<span class="notif-message">${escapeHtml(notif.message)}</span>
|
||||||
${sourceLabel}
|
${sourceLabel}
|
||||||
</div>
|
</div>
|
||||||
|
${hasDetails ? `<span class="notif-expand-icon">${expandIcon}</span>` : ''}
|
||||||
</div>
|
</div>
|
||||||
${detailsHtml}
|
${detailsHtml}
|
||||||
<div class="notif-meta">
|
<div class="notif-meta">
|
||||||
|
|||||||
@@ -160,10 +160,15 @@ function handleNotification(data) {
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case 'tool_execution':
|
case 'tool_execution':
|
||||||
// Handle tool execution notifications from CLI
|
// Handle tool execution notifications from MCP tools
|
||||||
handleToolExecutionNotification(payload);
|
handleToolExecutionNotification(payload);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'cli_execution':
|
||||||
|
// Handle CLI command notifications (ccw cli exec)
|
||||||
|
handleCliCommandNotification(payload);
|
||||||
|
break;
|
||||||
|
|
||||||
// CLI Tool Execution Events
|
// CLI Tool Execution Events
|
||||||
case 'CLI_EXECUTION_STARTED':
|
case 'CLI_EXECUTION_STARTED':
|
||||||
if (typeof handleCliExecutionStarted === 'function') {
|
if (typeof handleCliExecutionStarted === 'function') {
|
||||||
@@ -195,7 +200,7 @@ function handleNotification(data) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle tool execution notifications from CLI
|
* Handle tool execution notifications from MCP tools
|
||||||
* @param {Object} payload - Tool execution payload
|
* @param {Object} payload - Tool execution payload
|
||||||
*/
|
*/
|
||||||
function handleToolExecutionNotification(payload) {
|
function handleToolExecutionNotification(payload) {
|
||||||
@@ -210,19 +215,21 @@ function handleToolExecutionNotification(payload) {
|
|||||||
case 'started':
|
case 'started':
|
||||||
notifType = 'info';
|
notifType = 'info';
|
||||||
message = `Executing ${toolName}...`;
|
message = `Executing ${toolName}...`;
|
||||||
|
// Pass raw object for HTML formatting
|
||||||
if (params) {
|
if (params) {
|
||||||
details = formatJsonDetails(params, 150);
|
details = params;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'completed':
|
case 'completed':
|
||||||
notifType = 'success';
|
notifType = 'success';
|
||||||
message = `${toolName} completed`;
|
message = `${toolName} completed`;
|
||||||
|
// Pass raw object for HTML formatting
|
||||||
if (result) {
|
if (result) {
|
||||||
if (result._truncated) {
|
if (result._truncated) {
|
||||||
details = result.preview;
|
details = result.preview;
|
||||||
} else {
|
} else {
|
||||||
details = formatJsonDetails(result, 200);
|
details = result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
@@ -238,13 +245,89 @@ function handleToolExecutionNotification(payload) {
|
|||||||
message = `${toolName}: ${status}`;
|
message = `${toolName}: ${status}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add to global notifications
|
// Add to global notifications - pass objects directly for HTML formatting
|
||||||
|
if (typeof addGlobalNotification === 'function') {
|
||||||
|
addGlobalNotification(notifType, message, details, 'MCP');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log to console
|
||||||
|
console.log(`[MCP] ${status}: ${toolName}`, payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle CLI command notifications (ccw cli exec)
|
||||||
|
* @param {Object} payload - CLI execution payload
|
||||||
|
*/
|
||||||
|
function handleCliCommandNotification(payload) {
|
||||||
|
const { event, tool, mode, prompt_preview, execution_id, success, duration_ms, status, error, turn_count, custom_id } = payload;
|
||||||
|
|
||||||
|
let notifType = 'info';
|
||||||
|
let message = '';
|
||||||
|
let details = null;
|
||||||
|
|
||||||
|
switch (event) {
|
||||||
|
case 'started':
|
||||||
|
notifType = 'info';
|
||||||
|
message = `CLI ${tool} started`;
|
||||||
|
// Pass structured object for rich display
|
||||||
|
details = {
|
||||||
|
mode: mode,
|
||||||
|
prompt: prompt_preview
|
||||||
|
};
|
||||||
|
if (custom_id) {
|
||||||
|
details.id = custom_id;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'completed':
|
||||||
|
if (success) {
|
||||||
|
notifType = 'success';
|
||||||
|
const turnStr = turn_count > 1 ? ` (turn ${turn_count})` : '';
|
||||||
|
message = `CLI ${tool} completed${turnStr}`;
|
||||||
|
// Pass structured object for rich display
|
||||||
|
details = {
|
||||||
|
duration: duration_ms ? `${(duration_ms / 1000).toFixed(1)}s` : '-',
|
||||||
|
execution_id: execution_id
|
||||||
|
};
|
||||||
|
if (turn_count > 1) {
|
||||||
|
details.turns = turn_count;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
notifType = 'error';
|
||||||
|
message = `CLI ${tool} failed`;
|
||||||
|
details = {
|
||||||
|
status: status || 'Unknown error',
|
||||||
|
execution_id: execution_id
|
||||||
|
};
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'error':
|
||||||
|
notifType = 'error';
|
||||||
|
message = `CLI ${tool} error`;
|
||||||
|
details = error || 'Unknown error';
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
notifType = 'info';
|
||||||
|
message = `CLI ${tool}: ${event}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add to global notifications - pass objects for HTML formatting
|
||||||
if (typeof addGlobalNotification === 'function') {
|
if (typeof addGlobalNotification === 'function') {
|
||||||
addGlobalNotification(notifType, message, details, 'CLI');
|
addGlobalNotification(notifType, message, details, 'CLI');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Refresh CLI history if on history view
|
||||||
|
if (event === 'completed' && typeof currentView !== 'undefined' &&
|
||||||
|
(currentView === 'history' || currentView === 'cli-history')) {
|
||||||
|
if (typeof loadCliHistory === 'function' && typeof renderCliHistoryView === 'function') {
|
||||||
|
loadCliHistory().then(() => renderCliHistoryView());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Log to console
|
// Log to console
|
||||||
console.log(`[CLI] ${status}: ${toolName}`, payload);
|
console.log(`[CLI Command] ${event}: ${tool}`, payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========== Auto Refresh ==========
|
// ========== Auto Refresh ==========
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
// ==========================================
|
// ==========================================
|
||||||
// Right-side slide-out toolbar for task queue management
|
// Right-side slide-out toolbar for task queue management
|
||||||
|
|
||||||
let isTaskQueueVisible = false;
|
let isTaskQueueSidebarVisible = false;
|
||||||
let taskQueueData = [];
|
let taskQueueData = [];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -65,13 +65,13 @@ function initTaskQueueSidebar() {
|
|||||||
* Toggle task queue sidebar visibility
|
* Toggle task queue sidebar visibility
|
||||||
*/
|
*/
|
||||||
function toggleTaskQueueSidebar() {
|
function toggleTaskQueueSidebar() {
|
||||||
isTaskQueueVisible = !isTaskQueueVisible;
|
isTaskQueueSidebarVisible = !isTaskQueueSidebarVisible;
|
||||||
const sidebar = document.getElementById('taskQueueSidebar');
|
const sidebar = document.getElementById('taskQueueSidebar');
|
||||||
const overlay = document.getElementById('taskQueueOverlay');
|
const overlay = document.getElementById('taskQueueOverlay');
|
||||||
const toggle = document.getElementById('taskQueueToggle');
|
const toggle = document.getElementById('taskQueueToggle');
|
||||||
|
|
||||||
if (sidebar && overlay && toggle) {
|
if (sidebar && overlay && toggle) {
|
||||||
if (isTaskQueueVisible) {
|
if (isTaskQueueSidebarVisible) {
|
||||||
// Close notification sidebar if open
|
// Close notification sidebar if open
|
||||||
if (isNotificationPanelVisible && typeof toggleNotifSidebar === 'function') {
|
if (isNotificationPanelVisible && typeof toggleNotifSidebar === 'function') {
|
||||||
toggleNotifSidebar();
|
toggleNotifSidebar();
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Close task queue sidebar if open
|
// Close task queue sidebar if open
|
||||||
if (isTaskQueueVisible && typeof toggleTaskQueueSidebar === 'function') {
|
if (isTaskQueueSidebarVisible && typeof toggleTaskQueueSidebar === 'function') {
|
||||||
toggleTaskQueueSidebar();
|
toggleTaskQueueSidebar();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,15 +58,17 @@ async function renderCliManager() {
|
|||||||
|
|
||||||
container.innerHTML = '<div class="status-manager">' +
|
container.innerHTML = '<div class="status-manager">' +
|
||||||
'<div class="status-two-column">' +
|
'<div class="status-two-column">' +
|
||||||
'<div class="status-section" id="tools-section"></div>' +
|
'<div class="cli-section" id="tools-section"></div>' +
|
||||||
'<div class="status-section" id="ccw-section"></div>' +
|
'<div class="cli-section" id="ccw-section"></div>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<div class="status-section" id="ccw-endpoint-tools-section" style="margin-top: 1.5rem;"></div>' +
|
'<div class="cli-settings-section" id="cli-settings-section" style="margin-top: 1.5rem;"></div>' +
|
||||||
|
'<div class="cli-section" id="ccw-endpoint-tools-section" style="margin-top: 1.5rem;"></div>' +
|
||||||
'</div>';
|
'</div>';
|
||||||
|
|
||||||
// Render sub-panels
|
// Render sub-panels
|
||||||
renderToolsSection();
|
renderToolsSection();
|
||||||
renderCcwSection();
|
renderCcwSection();
|
||||||
|
renderCliSettingsSection();
|
||||||
renderCcwEndpointToolsSection();
|
renderCcwEndpointToolsSection();
|
||||||
|
|
||||||
// Initialize Lucide icons
|
// Initialize Lucide icons
|
||||||
@@ -241,6 +243,74 @@ function renderCcwSection() {
|
|||||||
if (window.lucide) lucide.createIcons();
|
if (window.lucide) lucide.createIcons();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========== CLI Settings Section (Full Width) ==========
|
||||||
|
function renderCliSettingsSection() {
|
||||||
|
var container = document.getElementById('cli-settings-section');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
var settingsHtml = '<div class="section-header">' +
|
||||||
|
'<div class="section-header-left">' +
|
||||||
|
'<h3><i data-lucide="settings" class="w-4 h-4"></i> ' + t('cli.settings') + '</h3>' +
|
||||||
|
'</div>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="cli-settings-grid">' +
|
||||||
|
'<div class="cli-setting-item">' +
|
||||||
|
'<label class="cli-setting-label">' +
|
||||||
|
'<i data-lucide="layers" class="w-3 h-3"></i>' +
|
||||||
|
t('cli.promptFormat') +
|
||||||
|
'</label>' +
|
||||||
|
'<div class="cli-setting-control">' +
|
||||||
|
'<select class="cli-setting-select" onchange="setPromptFormat(this.value)">' +
|
||||||
|
'<option value="plain"' + (promptConcatFormat === 'plain' ? ' selected' : '') + '>Plain Text</option>' +
|
||||||
|
'<option value="yaml"' + (promptConcatFormat === 'yaml' ? ' selected' : '') + '>YAML</option>' +
|
||||||
|
'<option value="json"' + (promptConcatFormat === 'json' ? ' selected' : '') + '>JSON</option>' +
|
||||||
|
'</select>' +
|
||||||
|
'</div>' +
|
||||||
|
'<p class="cli-setting-desc">' + t('cli.promptFormatDesc') + '</p>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="cli-setting-item">' +
|
||||||
|
'<label class="cli-setting-label">' +
|
||||||
|
'<i data-lucide="database" class="w-3 h-3"></i>' +
|
||||||
|
t('cli.storageBackend') +
|
||||||
|
'</label>' +
|
||||||
|
'<div class="cli-setting-control">' +
|
||||||
|
'<span class="cli-setting-value">SQLite</span>' +
|
||||||
|
'</div>' +
|
||||||
|
'<p class="cli-setting-desc">' + t('cli.storageBackendDesc') + '</p>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="cli-setting-item">' +
|
||||||
|
'<label class="cli-setting-label">' +
|
||||||
|
'<i data-lucide="sparkles" class="w-3 h-3"></i>' +
|
||||||
|
t('cli.smartContext') +
|
||||||
|
'</label>' +
|
||||||
|
'<div class="cli-setting-control">' +
|
||||||
|
'<label class="cli-toggle">' +
|
||||||
|
'<input type="checkbox"' + (smartContextEnabled ? ' checked' : '') + ' onchange="setSmartContextEnabled(this.checked)">' +
|
||||||
|
'<span class="cli-toggle-slider"></span>' +
|
||||||
|
'</label>' +
|
||||||
|
'</div>' +
|
||||||
|
'<p class="cli-setting-desc">' + t('cli.smartContextDesc') + '</p>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="cli-setting-item' + (!smartContextEnabled ? ' disabled' : '') + '">' +
|
||||||
|
'<label class="cli-setting-label">' +
|
||||||
|
'<i data-lucide="files" class="w-3 h-3"></i>' +
|
||||||
|
t('cli.maxContextFiles') +
|
||||||
|
'</label>' +
|
||||||
|
'<div class="cli-setting-control">' +
|
||||||
|
'<select class="cli-setting-select" onchange="setSmartContextMaxFiles(this.value)"' + (!smartContextEnabled ? ' disabled' : '') + '>' +
|
||||||
|
'<option value="5"' + (smartContextMaxFiles === 5 ? ' selected' : '') + '>5 files</option>' +
|
||||||
|
'<option value="10"' + (smartContextMaxFiles === 10 ? ' selected' : '') + '>10 files</option>' +
|
||||||
|
'<option value="20"' + (smartContextMaxFiles === 20 ? ' selected' : '') + '>20 files</option>' +
|
||||||
|
'</select>' +
|
||||||
|
'</div>' +
|
||||||
|
'<p class="cli-setting-desc">' + t('cli.maxContextFilesDesc') + '</p>' +
|
||||||
|
'</div>' +
|
||||||
|
'</div>';
|
||||||
|
|
||||||
|
container.innerHTML = settingsHtml;
|
||||||
|
if (window.lucide) lucide.createIcons();
|
||||||
|
}
|
||||||
|
|
||||||
// ========== CCW Endpoint Tools Section (Full Width) ==========
|
// ========== CCW Endpoint Tools Section (Full Width) ==========
|
||||||
function renderCcwEndpointToolsSection() {
|
function renderCcwEndpointToolsSection() {
|
||||||
var container = document.getElementById('ccw-endpoint-tools-section');
|
var container = document.getElementById('ccw-endpoint-tools-section');
|
||||||
@@ -744,7 +814,17 @@ async function executeCliFromDashboard() {
|
|||||||
var response = await fetch('/api/cli/execute', {
|
var response = await fetch('/api/cli/execute', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ tool: tool, mode: mode, prompt: prompt, dir: projectPath })
|
body: JSON.stringify({
|
||||||
|
tool: tool,
|
||||||
|
mode: mode,
|
||||||
|
prompt: prompt,
|
||||||
|
dir: projectPath,
|
||||||
|
format: promptConcatFormat,
|
||||||
|
smartContext: {
|
||||||
|
enabled: smartContextEnabled,
|
||||||
|
maxFiles: smartContextMaxFiles
|
||||||
|
}
|
||||||
|
})
|
||||||
});
|
});
|
||||||
var result = await response.json();
|
var result = await response.json();
|
||||||
|
|
||||||
@@ -777,6 +857,11 @@ function handleCliExecutionStarted(payload) {
|
|||||||
};
|
};
|
||||||
cliExecutionOutput = '';
|
cliExecutionOutput = '';
|
||||||
|
|
||||||
|
// Show toast notification
|
||||||
|
if (typeof addGlobalNotification === 'function') {
|
||||||
|
addGlobalNotification('info', 'CLI ' + payload.tool + ' started', payload.mode + ' mode', 'CLI');
|
||||||
|
}
|
||||||
|
|
||||||
if (currentView === 'cli-manager') {
|
if (currentView === 'cli-manager') {
|
||||||
var outputPanel = document.getElementById('cli-output-panel');
|
var outputPanel = document.getElementById('cli-output-panel');
|
||||||
var outputContent = document.getElementById('cli-output-content');
|
var outputContent = document.getElementById('cli-output-content');
|
||||||
@@ -806,6 +891,15 @@ function handleCliExecutionCompleted(payload) {
|
|||||||
if (statusIndicator) statusIndicator.className = 'status-indicator ' + (payload.success ? 'success' : 'error');
|
if (statusIndicator) statusIndicator.className = 'status-indicator ' + (payload.success ? 'success' : 'error');
|
||||||
if (statusText) statusText.textContent = payload.success ? 'Completed in ' + formatDuration(payload.duration_ms) : 'Failed: ' + payload.status;
|
if (statusText) statusText.textContent = payload.success ? 'Completed in ' + formatDuration(payload.duration_ms) : 'Failed: ' + payload.status;
|
||||||
|
|
||||||
|
// Show toast notification
|
||||||
|
if (typeof addGlobalNotification === 'function') {
|
||||||
|
if (payload.success) {
|
||||||
|
addGlobalNotification('success', 'CLI execution completed', formatDuration(payload.duration_ms), 'CLI');
|
||||||
|
} else {
|
||||||
|
addGlobalNotification('error', 'CLI execution failed', payload.status, 'CLI');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
currentCliExecution = null;
|
currentCliExecution = null;
|
||||||
if (currentView === 'cli-manager') {
|
if (currentView === 'cli-manager') {
|
||||||
loadCliHistory().then(function() { renderCliHistory(); });
|
loadCliHistory().then(function() { renderCliHistory(); });
|
||||||
@@ -819,5 +913,10 @@ function handleCliExecutionError(payload) {
|
|||||||
if (statusIndicator) statusIndicator.className = 'status-indicator error';
|
if (statusIndicator) statusIndicator.className = 'status-indicator error';
|
||||||
if (statusText) statusText.textContent = 'Error: ' + payload.error;
|
if (statusText) statusText.textContent = 'Error: ' + payload.error;
|
||||||
|
|
||||||
|
// Show toast notification
|
||||||
|
if (typeof addGlobalNotification === 'function') {
|
||||||
|
addGlobalNotification('error', 'CLI execution error', payload.error, 'CLI');
|
||||||
|
}
|
||||||
|
|
||||||
currentCliExecution = null;
|
currentCliExecution = null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ async function renderCliHistoryView() {
|
|||||||
'<button class="btn-icon" onclick="event.stopPropagation(); showExecutionDetail(\'' + exec.id + '\')" title="View Details">' +
|
'<button class="btn-icon" onclick="event.stopPropagation(); showExecutionDetail(\'' + exec.id + '\')" title="View Details">' +
|
||||||
'<i data-lucide="eye" class="w-4 h-4"></i>' +
|
'<i data-lucide="eye" class="w-4 h-4"></i>' +
|
||||||
'</button>' +
|
'</button>' +
|
||||||
'<button class="btn-icon btn-danger" onclick="event.stopPropagation(); confirmDeleteExecution(\'' + exec.id + '\')" title="Delete">' +
|
'<button class="btn-icon btn-danger" onclick="event.stopPropagation(); confirmDeleteExecution(\'' + exec.id + (exec.sourceDir ? '\',\'' + escapeHtml(exec.sourceDir) : '') + '\')" title="Delete">' +
|
||||||
'<i data-lucide="trash-2" class="w-4 h-4"></i>' +
|
'<i data-lucide="trash-2" class="w-4 h-4"></i>' +
|
||||||
'</button>' +
|
'</button>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ const ParamsSchema = z.object({
|
|||||||
tool: z.enum(['gemini', 'qwen', 'codex']),
|
tool: z.enum(['gemini', 'qwen', 'codex']),
|
||||||
prompt: z.string().min(1, 'Prompt is required'),
|
prompt: z.string().min(1, 'Prompt is required'),
|
||||||
mode: z.enum(['analysis', 'write', 'auto']).default('analysis'),
|
mode: z.enum(['analysis', 'write', 'auto']).default('analysis'),
|
||||||
|
format: z.enum(['plain', 'yaml', 'json']).default('plain'), // Multi-turn prompt concatenation format
|
||||||
model: z.string().optional(),
|
model: z.string().optional(),
|
||||||
cd: z.string().optional(),
|
cd: z.string().optional(),
|
||||||
includeDirs: z.string().optional(),
|
includeDirs: z.string().optional(),
|
||||||
@@ -50,6 +51,9 @@ const ParamsSchema = z.object({
|
|||||||
|
|
||||||
type Params = z.infer<typeof ParamsSchema>;
|
type Params = z.infer<typeof ParamsSchema>;
|
||||||
|
|
||||||
|
// Prompt concatenation format types
|
||||||
|
type PromptFormat = 'plain' | 'yaml' | 'json';
|
||||||
|
|
||||||
interface ToolAvailability {
|
interface ToolAvailability {
|
||||||
available: boolean;
|
available: boolean;
|
||||||
path: string | null;
|
path: string | null;
|
||||||
@@ -247,21 +251,6 @@ function ensureHistoryDir(baseDir: string): string {
|
|||||||
return historyDir;
|
return historyDir;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Load history index
|
|
||||||
*/
|
|
||||||
function loadHistoryIndex(historyDir: string): HistoryIndex {
|
|
||||||
const indexPath = join(historyDir, 'index.json');
|
|
||||||
if (existsSync(indexPath)) {
|
|
||||||
try {
|
|
||||||
return JSON.parse(readFileSync(indexPath, 'utf8'));
|
|
||||||
} catch {
|
|
||||||
return { version: 1, total_executions: 0, executions: [] };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { version: 1, total_executions: 0, executions: [] };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Save conversation to SQLite
|
* Save conversation to SQLite
|
||||||
*/
|
*/
|
||||||
@@ -384,29 +373,25 @@ function mergeConversations(conversations: ConversationRecord[]): MergeResult {
|
|||||||
/**
|
/**
|
||||||
* Build prompt from merged conversations
|
* Build prompt from merged conversations
|
||||||
*/
|
*/
|
||||||
function buildMergedPrompt(mergeResult: MergeResult, newPrompt: string): string {
|
function buildMergedPrompt(
|
||||||
const parts: string[] = [];
|
mergeResult: MergeResult,
|
||||||
|
newPrompt: string,
|
||||||
|
format: PromptFormat = 'plain'
|
||||||
|
): string {
|
||||||
|
const concatenator = createPromptConcatenator({ format });
|
||||||
|
|
||||||
parts.push('=== MERGED CONVERSATION HISTORY ===');
|
// Set metadata for merged conversations
|
||||||
parts.push(`(From ${mergeResult.sourceConversations.length} conversations: ${mergeResult.sourceConversations.map(c => c.id).join(', ')})`);
|
concatenator.setMetadata(
|
||||||
parts.push('');
|
'merged_sources',
|
||||||
|
mergeResult.sourceConversations.map(c => c.id).join(', ')
|
||||||
|
);
|
||||||
|
|
||||||
// Add all merged turns with source tracking
|
// Add all merged turns with source tracking
|
||||||
for (const turn of mergeResult.mergedTurns) {
|
for (const turn of mergeResult.mergedTurns) {
|
||||||
parts.push(`--- Turn ${turn.turn} [${turn.source_id}] ---`);
|
concatenator.addFromConversationTurn(turn, turn.source_id);
|
||||||
parts.push('USER:');
|
|
||||||
parts.push(turn.prompt);
|
|
||||||
parts.push('');
|
|
||||||
parts.push('ASSISTANT:');
|
|
||||||
parts.push(turn.output.stdout || '[No output recorded]');
|
|
||||||
parts.push('');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
parts.push('=== NEW REQUEST ===');
|
return concatenator.build(newPrompt);
|
||||||
parts.push('');
|
|
||||||
parts.push(newPrompt);
|
|
||||||
|
|
||||||
return parts.join('\n');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -421,7 +406,7 @@ async function executeCliTool(
|
|||||||
throw new Error(`Invalid params: ${parsed.error.message}`);
|
throw new Error(`Invalid params: ${parsed.error.message}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { tool, prompt, mode, model, cd, includeDirs, timeout, resume, id: customId } = parsed.data;
|
const { tool, prompt, mode, format, model, cd, includeDirs, timeout, resume, id: customId } = parsed.data;
|
||||||
|
|
||||||
// Determine working directory early (needed for conversation lookup)
|
// Determine working directory early (needed for conversation lookup)
|
||||||
const workingDir = cd || process.cwd();
|
const workingDir = cd || process.cwd();
|
||||||
@@ -505,11 +490,11 @@ async function executeCliTool(
|
|||||||
// For append: use existingConversation (from target ID)
|
// For append: use existingConversation (from target ID)
|
||||||
let finalPrompt = prompt;
|
let finalPrompt = prompt;
|
||||||
if (mergeResult && mergeResult.mergedTurns.length > 0) {
|
if (mergeResult && mergeResult.mergedTurns.length > 0) {
|
||||||
finalPrompt = buildMergedPrompt(mergeResult, prompt);
|
finalPrompt = buildMergedPrompt(mergeResult, prompt, format);
|
||||||
} else {
|
} else {
|
||||||
const conversationForContext = contextConversation || existingConversation;
|
const conversationForContext = contextConversation || existingConversation;
|
||||||
if (conversationForContext && conversationForContext.turns.length > 0) {
|
if (conversationForContext && conversationForContext.turns.length > 0) {
|
||||||
finalPrompt = buildMultiTurnPrompt(conversationForContext, prompt);
|
finalPrompt = buildMultiTurnPrompt(conversationForContext, prompt, format);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -845,9 +830,9 @@ function findCliHistoryDirs(baseDir: string, maxDepth: number = 3): string[] {
|
|||||||
function scanDir(dir: string, depth: number) {
|
function scanDir(dir: string, depth: number) {
|
||||||
if (depth > maxDepth) return;
|
if (depth > maxDepth) return;
|
||||||
|
|
||||||
// Check if this directory has CLI history
|
// Check if this directory has CLI history (SQLite database)
|
||||||
const historyDir = join(dir, '.workflow', '.cli-history');
|
const historyDir = join(dir, '.workflow', '.cli-history');
|
||||||
if (existsSync(join(historyDir, 'index.json'))) {
|
if (existsSync(join(historyDir, 'history.db'))) {
|
||||||
historyDirs.push(historyDir);
|
historyDirs.push(historyDir);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1047,11 +1032,6 @@ export async function getCliToolsStatus(): Promise<Record<string, ToolAvailabili
|
|||||||
|
|
||||||
// ========== Prompt Concatenation System ==========
|
// ========== Prompt Concatenation System ==========
|
||||||
|
|
||||||
/**
|
|
||||||
* Supported prompt concatenation formats
|
|
||||||
*/
|
|
||||||
type PromptFormat = 'plain' | 'yaml' | 'json';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Turn data structure for concatenation
|
* Turn data structure for concatenation
|
||||||
*/
|
*/
|
||||||
@@ -1477,7 +1457,7 @@ export { executeCliTool, checkToolAvailability };
|
|||||||
export { PromptConcatenator, createPromptConcatenator, buildPrompt, buildMultiTurnPrompt };
|
export { PromptConcatenator, createPromptConcatenator, buildPrompt, buildMultiTurnPrompt };
|
||||||
|
|
||||||
// Note: Async storage functions (getExecutionHistoryAsync, deleteExecutionAsync,
|
// Note: Async storage functions (getExecutionHistoryAsync, deleteExecutionAsync,
|
||||||
// batchDeleteExecutionsAsync, setStorageBackend) are exported at declaration site
|
// batchDeleteExecutionsAsync) are exported at declaration site - SQLite storage only
|
||||||
|
|
||||||
// Export tool definition (for legacy imports) - This allows direct calls to execute with onOutput
|
// Export tool definition (for legacy imports) - This allows direct calls to execute with onOutput
|
||||||
export const cliExecutorTool = {
|
export const cliExecutorTool = {
|
||||||
|
|||||||
@@ -52,6 +52,9 @@ interface UpdateModeResult {
|
|||||||
message: string;
|
message: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Max lines to show in diff preview
|
||||||
|
const MAX_DIFF_LINES = 15;
|
||||||
|
|
||||||
interface LineModeResult {
|
interface LineModeResult {
|
||||||
content: string;
|
content: string;
|
||||||
modified: boolean;
|
modified: boolean;
|
||||||
@@ -61,7 +64,7 @@ interface LineModeResult {
|
|||||||
message: string;
|
message: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
type EditResult = Omit<UpdateModeResult | LineModeResult, 'content'>;
|
// Internal type for mode results (content excluded in final output)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve file path and read content
|
* Resolve file path and read content
|
||||||
@@ -485,8 +488,30 @@ Options: dryRun=true (preview diff), replaceAll=true (replace all occurrences)`,
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Truncate diff to max lines with indicator
|
||||||
|
*/
|
||||||
|
function truncateDiff(diff: string, maxLines: number): string {
|
||||||
|
if (!diff) return '';
|
||||||
|
const lines = diff.split('\n');
|
||||||
|
if (lines.length <= maxLines) return diff;
|
||||||
|
return lines.slice(0, maxLines).join('\n') + `\n... (+${lines.length - maxLines} more lines)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build compact result for output
|
||||||
|
*/
|
||||||
|
interface CompactEditResult {
|
||||||
|
path: string;
|
||||||
|
modified: boolean;
|
||||||
|
message: string;
|
||||||
|
replacements?: number;
|
||||||
|
diff?: string;
|
||||||
|
dryRun?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
// Handler function
|
// Handler function
|
||||||
export async function handler(params: Record<string, unknown>): Promise<ToolResult<EditResult>> {
|
export async function handler(params: Record<string, unknown>): Promise<ToolResult<CompactEditResult>> {
|
||||||
const parsed = ParamsSchema.safeParse(params);
|
const parsed = ParamsSchema.safeParse(params);
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
return { success: false, error: `Invalid params: ${parsed.error.message}` };
|
return { success: false, error: `Invalid params: ${parsed.error.message}` };
|
||||||
@@ -514,9 +539,24 @@ export async function handler(params: Record<string, unknown>): Promise<ToolResu
|
|||||||
writeFile(resolvedPath, result.content);
|
writeFile(resolvedPath, result.content);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove content from result
|
// Build compact result
|
||||||
const { content: _, ...output } = result;
|
const compactResult: CompactEditResult = {
|
||||||
return { success: true, result: output as EditResult };
|
path: resolvedPath,
|
||||||
|
modified: result.modified,
|
||||||
|
message: result.message,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add mode-specific fields
|
||||||
|
if ('replacements' in result) {
|
||||||
|
compactResult.replacements = result.replacements;
|
||||||
|
compactResult.dryRun = result.dryRun;
|
||||||
|
// Truncate diff for compact output
|
||||||
|
if (result.diff) {
|
||||||
|
compactResult.diff = truncateDiff(result.diff, MAX_DIFF_LINES);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, result: compactResult };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return { success: false, error: (error as Error).message };
|
return { success: false, error: (error as Error).message };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import * as sessionManagerMod from './session-manager.js';
|
|||||||
import * as cliExecutorMod from './cli-executor.js';
|
import * as cliExecutorMod from './cli-executor.js';
|
||||||
import * as smartSearchMod from './smart-search.js';
|
import * as smartSearchMod from './smart-search.js';
|
||||||
import * as codexLensMod from './codex-lens.js';
|
import * as codexLensMod from './codex-lens.js';
|
||||||
|
import * as readFileMod from './read-file.js';
|
||||||
|
|
||||||
// Import legacy JS tools
|
// Import legacy JS tools
|
||||||
import { uiGeneratePreviewTool } from './ui-generate-preview.js';
|
import { uiGeneratePreviewTool } from './ui-generate-preview.js';
|
||||||
@@ -297,6 +298,7 @@ registerTool(toLegacyTool(sessionManagerMod));
|
|||||||
registerTool(toLegacyTool(cliExecutorMod));
|
registerTool(toLegacyTool(cliExecutorMod));
|
||||||
registerTool(toLegacyTool(smartSearchMod));
|
registerTool(toLegacyTool(smartSearchMod));
|
||||||
registerTool(toLegacyTool(codexLensMod));
|
registerTool(toLegacyTool(codexLensMod));
|
||||||
|
registerTool(toLegacyTool(readFileMod));
|
||||||
|
|
||||||
// Register legacy JS tools
|
// Register legacy JS tools
|
||||||
registerTool(uiGeneratePreviewTool);
|
registerTool(uiGeneratePreviewTool);
|
||||||
|
|||||||
325
ccw/src/tools/read-file.ts
Normal file
325
ccw/src/tools/read-file.ts
Normal file
@@ -0,0 +1,325 @@
|
|||||||
|
/**
|
||||||
|
* Read File Tool - Read files with multi-file, directory, and regex support
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - Read single or multiple files
|
||||||
|
* - Read all files in a directory (with depth control)
|
||||||
|
* - Filter files by glob/regex pattern
|
||||||
|
* - Content search with regex
|
||||||
|
* - Compact output format
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { z } from 'zod';
|
||||||
|
import type { ToolSchema, ToolResult } from '../types/tool.js';
|
||||||
|
import { readFileSync, readdirSync, statSync, existsSync } from 'fs';
|
||||||
|
import { resolve, isAbsolute, join, relative, extname } from 'path';
|
||||||
|
|
||||||
|
// Max content per file (truncate if larger)
|
||||||
|
const MAX_CONTENT_LENGTH = 5000;
|
||||||
|
// Max files to return
|
||||||
|
const MAX_FILES = 50;
|
||||||
|
// Max total content length
|
||||||
|
const MAX_TOTAL_CONTENT = 50000;
|
||||||
|
|
||||||
|
// Define Zod schema for validation
|
||||||
|
const ParamsSchema = z.object({
|
||||||
|
paths: z.union([z.string(), z.array(z.string())]).describe('File path(s) or directory'),
|
||||||
|
pattern: z.string().optional().describe('Glob pattern to filter files (e.g., "*.ts", "**/*.js")'),
|
||||||
|
contentPattern: z.string().optional().describe('Regex to search within file content'),
|
||||||
|
maxDepth: z.number().default(3).describe('Max directory depth to traverse'),
|
||||||
|
includeContent: z.boolean().default(true).describe('Include file content in result'),
|
||||||
|
maxFiles: z.number().default(MAX_FILES).describe('Max number of files to return'),
|
||||||
|
});
|
||||||
|
|
||||||
|
type Params = z.infer<typeof ParamsSchema>;
|
||||||
|
|
||||||
|
interface FileEntry {
|
||||||
|
path: string;
|
||||||
|
size: number;
|
||||||
|
content?: string;
|
||||||
|
truncated?: boolean;
|
||||||
|
matches?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ReadResult {
|
||||||
|
files: FileEntry[];
|
||||||
|
totalFiles: number;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Common binary extensions to skip
|
||||||
|
const BINARY_EXTENSIONS = new Set([
|
||||||
|
'.png', '.jpg', '.jpeg', '.gif', '.bmp', '.ico', '.webp', '.svg',
|
||||||
|
'.pdf', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx',
|
||||||
|
'.zip', '.tar', '.gz', '.rar', '.7z',
|
||||||
|
'.exe', '.dll', '.so', '.dylib',
|
||||||
|
'.mp3', '.mp4', '.wav', '.avi', '.mov',
|
||||||
|
'.woff', '.woff2', '.ttf', '.eot', '.otf',
|
||||||
|
'.pyc', '.class', '.o', '.obj',
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if file is likely binary
|
||||||
|
*/
|
||||||
|
function isBinaryFile(filePath: string): boolean {
|
||||||
|
const ext = extname(filePath).toLowerCase();
|
||||||
|
return BINARY_EXTENSIONS.has(ext);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert glob pattern to regex
|
||||||
|
*/
|
||||||
|
function globToRegex(pattern: string): RegExp {
|
||||||
|
const escaped = pattern
|
||||||
|
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
||||||
|
.replace(/\*/g, '.*')
|
||||||
|
.replace(/\?/g, '.');
|
||||||
|
return new RegExp(`^${escaped}$`, 'i');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if filename matches glob pattern
|
||||||
|
*/
|
||||||
|
function matchesPattern(filename: string, pattern: string): boolean {
|
||||||
|
const regex = globToRegex(pattern);
|
||||||
|
return regex.test(filename);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recursively collect files from directory
|
||||||
|
*/
|
||||||
|
function collectFiles(
|
||||||
|
dir: string,
|
||||||
|
pattern: string | undefined,
|
||||||
|
maxDepth: number,
|
||||||
|
currentDepth: number = 0
|
||||||
|
): string[] {
|
||||||
|
if (currentDepth > maxDepth) return [];
|
||||||
|
|
||||||
|
const files: string[] = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const entries = readdirSync(dir, { withFileTypes: true });
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
// Skip hidden files/dirs and node_modules
|
||||||
|
if (entry.name.startsWith('.') || entry.name === 'node_modules') continue;
|
||||||
|
|
||||||
|
const fullPath = join(dir, entry.name);
|
||||||
|
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
files.push(...collectFiles(fullPath, pattern, maxDepth, currentDepth + 1));
|
||||||
|
} else if (entry.isFile()) {
|
||||||
|
if (!pattern || matchesPattern(entry.name, pattern)) {
|
||||||
|
files.push(fullPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Skip directories we can't read
|
||||||
|
}
|
||||||
|
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read file content with truncation
|
||||||
|
*/
|
||||||
|
function readFileContent(filePath: string, maxLength: number): { content: string; truncated: boolean } {
|
||||||
|
if (isBinaryFile(filePath)) {
|
||||||
|
return { content: '[Binary file]', truncated: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const content = readFileSync(filePath, 'utf8');
|
||||||
|
if (content.length > maxLength) {
|
||||||
|
return {
|
||||||
|
content: content.substring(0, maxLength) + `\n... (+${content.length - maxLength} chars)`,
|
||||||
|
truncated: true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { content, truncated: false };
|
||||||
|
} catch (error) {
|
||||||
|
return { content: `[Error: ${(error as Error).message}]`, truncated: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find regex matches in content
|
||||||
|
*/
|
||||||
|
function findMatches(content: string, pattern: string): string[] {
|
||||||
|
try {
|
||||||
|
const regex = new RegExp(pattern, 'gm');
|
||||||
|
const matches: string[] = [];
|
||||||
|
let match;
|
||||||
|
|
||||||
|
while ((match = regex.exec(content)) !== null && matches.length < 10) {
|
||||||
|
// Get line containing match
|
||||||
|
const lineStart = content.lastIndexOf('\n', match.index) + 1;
|
||||||
|
const lineEnd = content.indexOf('\n', match.index);
|
||||||
|
const line = content.substring(lineStart, lineEnd === -1 ? undefined : lineEnd).trim();
|
||||||
|
matches.push(line.substring(0, 200)); // Truncate long lines
|
||||||
|
}
|
||||||
|
|
||||||
|
return matches;
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tool schema for MCP
|
||||||
|
export const schema: ToolSchema = {
|
||||||
|
name: 'read_file',
|
||||||
|
description: `Read files with multi-file, directory, and regex support.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
read_file(paths="file.ts") # Single file
|
||||||
|
read_file(paths=["a.ts", "b.ts"]) # Multiple files
|
||||||
|
read_file(paths="src/", pattern="*.ts") # Directory with pattern
|
||||||
|
read_file(paths="src/", contentPattern="TODO") # Search content
|
||||||
|
|
||||||
|
Returns compact file list with optional content.`,
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
paths: {
|
||||||
|
oneOf: [
|
||||||
|
{ type: 'string', description: 'Single file or directory path' },
|
||||||
|
{ type: 'array', items: { type: 'string' }, description: 'Array of file paths' }
|
||||||
|
],
|
||||||
|
description: 'File path(s) or directory to read',
|
||||||
|
},
|
||||||
|
pattern: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Glob pattern to filter files (e.g., "*.ts", "*.{js,ts}")',
|
||||||
|
},
|
||||||
|
contentPattern: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Regex pattern to search within file content',
|
||||||
|
},
|
||||||
|
maxDepth: {
|
||||||
|
type: 'number',
|
||||||
|
description: 'Max directory depth to traverse (default: 3)',
|
||||||
|
default: 3,
|
||||||
|
},
|
||||||
|
includeContent: {
|
||||||
|
type: 'boolean',
|
||||||
|
description: 'Include file content in result (default: true)',
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
maxFiles: {
|
||||||
|
type: 'number',
|
||||||
|
description: `Max number of files to return (default: ${MAX_FILES})`,
|
||||||
|
default: MAX_FILES,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['paths'],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handler function
|
||||||
|
export async function handler(params: Record<string, unknown>): Promise<ToolResult<ReadResult>> {
|
||||||
|
const parsed = ParamsSchema.safeParse(params);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return { success: false, error: `Invalid params: ${parsed.error.message}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
paths,
|
||||||
|
pattern,
|
||||||
|
contentPattern,
|
||||||
|
maxDepth,
|
||||||
|
includeContent,
|
||||||
|
maxFiles,
|
||||||
|
} = parsed.data;
|
||||||
|
|
||||||
|
const cwd = process.cwd();
|
||||||
|
|
||||||
|
// Normalize paths to array
|
||||||
|
const inputPaths = Array.isArray(paths) ? paths : [paths];
|
||||||
|
|
||||||
|
// Collect all files to read
|
||||||
|
const allFiles: string[] = [];
|
||||||
|
|
||||||
|
for (const inputPath of inputPaths) {
|
||||||
|
const resolvedPath = isAbsolute(inputPath) ? inputPath : resolve(cwd, inputPath);
|
||||||
|
|
||||||
|
if (!existsSync(resolvedPath)) {
|
||||||
|
continue; // Skip non-existent paths
|
||||||
|
}
|
||||||
|
|
||||||
|
const stat = statSync(resolvedPath);
|
||||||
|
|
||||||
|
if (stat.isDirectory()) {
|
||||||
|
// Collect files from directory
|
||||||
|
const dirFiles = collectFiles(resolvedPath, pattern, maxDepth);
|
||||||
|
allFiles.push(...dirFiles);
|
||||||
|
} else if (stat.isFile()) {
|
||||||
|
// Add single file (check pattern if provided)
|
||||||
|
if (!pattern || matchesPattern(relative(cwd, resolvedPath), pattern)) {
|
||||||
|
allFiles.push(resolvedPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Limit files
|
||||||
|
const limitedFiles = allFiles.slice(0, maxFiles);
|
||||||
|
const totalFiles = allFiles.length;
|
||||||
|
|
||||||
|
// Process files
|
||||||
|
const files: FileEntry[] = [];
|
||||||
|
let totalContent = 0;
|
||||||
|
|
||||||
|
for (const filePath of limitedFiles) {
|
||||||
|
if (totalContent >= MAX_TOTAL_CONTENT) break;
|
||||||
|
|
||||||
|
const stat = statSync(filePath);
|
||||||
|
const entry: FileEntry = {
|
||||||
|
path: relative(cwd, filePath) || filePath,
|
||||||
|
size: stat.size,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (includeContent) {
|
||||||
|
const remainingSpace = MAX_TOTAL_CONTENT - totalContent;
|
||||||
|
const maxLen = Math.min(MAX_CONTENT_LENGTH, remainingSpace);
|
||||||
|
const { content, truncated } = readFileContent(filePath, maxLen);
|
||||||
|
|
||||||
|
// If contentPattern provided, only include files with matches
|
||||||
|
if (contentPattern) {
|
||||||
|
const matches = findMatches(content, contentPattern);
|
||||||
|
if (matches.length > 0) {
|
||||||
|
entry.matches = matches;
|
||||||
|
entry.content = content;
|
||||||
|
entry.truncated = truncated;
|
||||||
|
totalContent += content.length;
|
||||||
|
} else {
|
||||||
|
continue; // Skip files without matches
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
entry.content = content;
|
||||||
|
entry.truncated = truncated;
|
||||||
|
totalContent += content.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
files.push(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build message
|
||||||
|
let message = `Read ${files.length} file(s)`;
|
||||||
|
if (totalFiles > maxFiles) {
|
||||||
|
message += ` (showing ${maxFiles} of ${totalFiles})`;
|
||||||
|
}
|
||||||
|
if (contentPattern) {
|
||||||
|
message += ` matching "${contentPattern}"`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
result: {
|
||||||
|
files,
|
||||||
|
totalFiles,
|
||||||
|
message,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -24,12 +24,9 @@ const ParamsSchema = z.object({
|
|||||||
|
|
||||||
type Params = z.infer<typeof ParamsSchema>;
|
type Params = z.infer<typeof ParamsSchema>;
|
||||||
|
|
||||||
|
// Compact result for output
|
||||||
interface WriteResult {
|
interface WriteResult {
|
||||||
success: boolean;
|
|
||||||
path: string;
|
path: string;
|
||||||
created: boolean;
|
|
||||||
overwritten: boolean;
|
|
||||||
backupPath: string | null;
|
|
||||||
bytes: number;
|
bytes: number;
|
||||||
message: string;
|
message: string;
|
||||||
}
|
}
|
||||||
@@ -153,19 +150,24 @@ export async function handler(params: Record<string, unknown>): Promise<ToolResu
|
|||||||
// Write file
|
// Write file
|
||||||
try {
|
try {
|
||||||
writeFileSync(resolvedPath, content, { encoding });
|
writeFileSync(resolvedPath, content, { encoding });
|
||||||
|
const bytes = Buffer.byteLength(content, encoding);
|
||||||
|
|
||||||
|
// Build compact message
|
||||||
|
let message: string;
|
||||||
|
if (fileExists) {
|
||||||
|
message = backupPath
|
||||||
|
? `Overwrote (${bytes}B, backup: ${basename(backupPath)})`
|
||||||
|
: `Overwrote (${bytes}B)`;
|
||||||
|
} else {
|
||||||
|
message = `Created (${bytes}B)`;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
result: {
|
result: {
|
||||||
success: true,
|
|
||||||
path: resolvedPath,
|
path: resolvedPath,
|
||||||
created: !fileExists,
|
bytes,
|
||||||
overwritten: fileExists,
|
message,
|
||||||
backupPath,
|
|
||||||
bytes: Buffer.byteLength(content, encoding),
|
|
||||||
message: fileExists
|
|
||||||
? `Successfully overwrote ${filePath}${backupPath ? ` (backup: ${backupPath})` : ''}`
|
|
||||||
: `Successfully created ${filePath}`,
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
Reference in New Issue
Block a user