Release 2.10.0: 코드 에디터 터미널 초기화 버그 수정 + cls/clear 자동 실행
- requestAnimationFrame → setTimeout(60ms): 패널 레이아웃 계산 후 xterm 초기화 - ptyReady 스냅샷 버그 수정: codeTermShells 대신 xtermInstances 참조 - 터미널 열릴 때 cls/clear 자동 실행 (Windows: cls, Linux: clear) - companionPlatform 타이밍 버그 수정: clearCmd를 shell_ready 수신 후 평가 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "smallclaw",
|
||||
"version": "2.9.7",
|
||||
"version": "2.10.0",
|
||||
"description": "Local AI agent framework powered by Ollama - OpenClaw alternative",
|
||||
"main": "dist/index.js",
|
||||
"bin": {
|
||||
|
||||
+95
-95
@@ -468,10 +468,10 @@ const IMAGE_TYPES: Record<string, string> = {
|
||||
|
||||
type SubagentProfile = 'file_editor' | 'researcher' | 'shell_runner' | 'reader_only';
|
||||
const TOOL_PROFILES: Record<SubagentProfile, Set<string>> = {
|
||||
file_editor: new Set(['read_file', 'create_file', 'replace_lines', 'insert_after', 'delete_lines', 'find_replace', 'list_files']),
|
||||
researcher: new Set(['read_file', 'list_files', 'web_search', 'web_fetch']),
|
||||
shell_runner: new Set(['run_command', 'read_file', 'list_files']),
|
||||
reader_only: new Set(['read_file', 'list_files']),
|
||||
file_editor: new Set(['coder_read_file', 'coder_write_file', 'coder_overwrite_lines', 'coder_insert_lines', 'coder_delete_lines', 'coder_str_replace', 'coder_list_files']),
|
||||
researcher: new Set(['coder_read_file', 'coder_list_files', 'web_search', 'web_fetch']),
|
||||
shell_runner: new Set(['run_command', 'coder_read_file', 'coder_list_files']),
|
||||
reader_only: new Set(['coder_read_file', 'coder_list_files']),
|
||||
};
|
||||
|
||||
// Track last-used filename per session for when model forgets to pass it
|
||||
@@ -1248,13 +1248,13 @@ const TOOL_BLOCKS: Record<string, string> = {
|
||||
|
||||
desktop: `DESKTOP TOOLS: desktop_screenshot() → capture+OCR. desktop_find_window(name) → find window. desktop_focus_window(name) → bring to front (use SHORT process name: msedge, chrome, code). desktop_click(x,y,button) → click coords (button: "left" or "right"). desktop_type(text) → type. desktop_press_key(key) → key combo. desktop_drag(x1,y1,x2,y2) → drag. desktop_get_clipboard()/set_clipboard(text). Always screenshot first. Focus window before click/type. Fail twice on focus → stop and report. IMAGE DOWNLOAD: prefer shell("curl -o <path> <url>") or shell("python -c ...urllib...") for downloading images. Only use desktop right-click (desktop_click(x,y,"right") → screenshot → click "Save as") as a last resort when shell download fails due to auth or hotlink protection.`,
|
||||
|
||||
files: `FILE TOOLS: read_file(filename) → contents+line numbers. create_file(f,content) → create new file (if file exists, auto-converts to full replace_lines edit). replace_lines(f,start,end,content) → surgical edit. insert_after(f,line,content). delete_lines(f,start,end). find_replace(f,find,replace). delete_file(f). list_files(dir?). RULES: list first; read before edit; for small changes prefer replace_lines or insert_after over rewriting the whole file. CODE FILES: When writing code (scripts, programs, source files), always save under the code directory. For NEW files use create_file("code/filename", content). For EDITING existing code files use replace_lines or insert_after with the same "code/filename" path. This keeps code files organized and visible in the Code editor tab. FILE LINKS — STRICT: NEVER manually construct /api/files/... paths from memory or assumption. Always use the EXACT path returned by the tool that created/saved the file. If you don't have a path, call list_files FIRST to find the actual location, then build the link from that result. Common locations (for context only — verify before using): Code files → code/, UI uploads → uploads/, email attachments → attachments/uid-{N}/, pubmed_fulltext/shell-saved files → workspace root or task subfolder, PPTX → <project-folder>/. Wrong-folder guesses produce broken download buttons. EXCEPTION: MCP tool results (e.g. dental-dict-sqlite) that return an image_url field — output that value directly as  markdown, no list_objects needed.`,
|
||||
files: `FILE TOOLS: coder_read_file(filename) → contents+line numbers. coder_write_file(f,content) → create new file (if file exists, auto-converts to full coder_overwrite_lines edit). coder_overwrite_lines(f,start,end,content) → surgical edit. coder_insert_lines(f,line,content). coder_delete_lines(f,start,end). coder_str_replace(f,find,replace). coder_delete_file(f). coder_list_files(dir?). RULES: list first; read before edit; for small changes prefer coder_overwrite_lines or coder_insert_lines over rewriting the whole file. CODE FILES: When writing code (scripts, programs, source files), always save under the code directory. For NEW files use coder_write_file("code/filename", content). For EDITING existing code files use coder_overwrite_lines or coder_insert_lines with the same "code/filename" path. This keeps code files organized and visible in the Code editor tab. FILE LINKS — STRICT: NEVER manually construct /api/files/... paths from memory or assumption. Always use the EXACT path returned by the tool that created/saved the file. If you don't have a path, call coder_list_files FIRST to find the actual location, then build the link from that result. Common locations (for context only — verify before using): Code files → code/, UI uploads → uploads/, email attachments → attachments/uid-{N}/, pubmed_fulltext/shell-saved files → workspace root or task subfolder, PPTX → <project-folder>/. Wrong-folder guesses produce broken download buttons. EXCEPTION: MCP tool results (e.g. dental-dict-sqlite) that return an image_url field — output that value directly as  markdown, no list_objects needed.`,
|
||||
|
||||
task: `TASK TOOLS: task_control(action,...) actions: list/get/resume/rerun/pause/delete. start_task(title,prompt) → launch new background task. Check for existing tasks first before creating — never duplicate. Do NOT use read_file to check task state.`,
|
||||
task: `TASK TOOLS: task_control(action,...) actions: list/get/resume/rerun/pause/delete. start_task(title,prompt) → launch new background task. Check for existing tasks first before creating — never duplicate. Do NOT use coder_read_file to check task state.`,
|
||||
|
||||
schedule: `SCHEDULE TOOL: schedule_job(action,...) actions: list/create/update/pause/resume/delete/run_now. Always confirm before create/update/delete. Keep schedule timing separate from instruction_prompt content.`,
|
||||
|
||||
shell: `SHELL TOOL: shell(command) → execute terminal commands (python, pip, node, npm, dir, etc.). Returns command output. Use shell for scripts, CLI tools, and any terminal command. For opening GUI apps for the user to see, use run_command (chrome, notepad, vscode). For web automation use browser_* not shell. For desktop interaction use desktop_* not shell. IMPORTANT: NEVER use heredoc syntax (<<'PY', <<'EOF', etc.) — it only works in bash, NOT in PowerShell. Instead: (1) write the script to a .py file using create_file, then (2) tell the user the file is ready. ONLY run the script with shell("python script.py") if the user EXPLICITLY asks to run/execute/실행 it. For one-liners use shell("python -c \\"code\\""). DOWNLOAD IMAGES: shell("curl -L -o <filepath> <url>") is the best way to download images/files. NEVER use xdg-open or open to launch browsers — the user can open files themselves from the UI.`,
|
||||
shell: `SHELL TOOL: shell(command) → execute terminal commands (python, pip, node, npm, dir, etc.). Returns command output. Use shell for scripts, CLI tools, and any terminal command. For opening GUI apps for the user to see, use run_command (chrome, notepad, vscode). For web automation use browser_* not shell. For desktop interaction use desktop_* not shell. IMPORTANT: NEVER use heredoc syntax (<<'PY', <<'EOF', etc.) — it only works in bash, NOT in PowerShell. Instead: (1) write the script to a .py file using coder_write_file, then (2) tell the user the file is ready. ONLY run the script with shell("python script.py") if the user EXPLICITLY asks to run/execute/실행 it. For one-liners use shell("python -c \\"code\\""). DOWNLOAD IMAGES: shell("curl -L -o <filepath> <url>") is the best way to download images/files. NEVER use xdg-open or open to launch browsers — the user can open files themselves from the UI.`,
|
||||
|
||||
memory: `MEMORY TOOLS: memory_browse(file) → list categories in user.md or soul.md. memory_write(file,category,content) → add/update a fact (creates category if new). memory_read(file) → full file contents. File is "user" or "soul". Browse first to find the right category. Write immediately when you learn something — don't wait.`,
|
||||
|
||||
@@ -1506,7 +1506,7 @@ async function buildPersonalityContext(
|
||||
|
||||
// Tier 3: complex/multi-domain — add tools.md hint
|
||||
const isComplex = cats.size >= 3 || /\b(how do i|help me|can you|i need to|i want to)\b/i.test(messageText);
|
||||
const toolsHint = isComplex ? `\nFor full tool reference: read_file prompts/TOOLS.md` : '';
|
||||
const toolsHint = isComplex ? `\nFor full tool reference: coder_read_file prompts/TOOLS.md` : '';
|
||||
|
||||
// Self.md for debug (cached with fileCache)
|
||||
const self = cats.has('debug') ? loadFile('SELF.md', 2000) : '';
|
||||
@@ -1556,7 +1556,7 @@ function buildTools() {
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'list_files',
|
||||
name: 'coder_list_files',
|
||||
description: 'List files and subdirectories in the workspace (or a subdirectory). Use "directory" to browse subdirectories like "uploads".',
|
||||
parameters: { type: 'object', properties: { directory: { type: 'string', description: 'Subdirectory path relative to workspace (e.g. "uploads"). Omit to list workspace root.' } }, required: [] },
|
||||
},
|
||||
@@ -1564,7 +1564,7 @@ function buildTools() {
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'read_file',
|
||||
name: 'coder_read_file',
|
||||
description: 'Read a file and return its content WITH line numbers. For images, returns a displayable image. Always use this before editing a file.',
|
||||
parameters: {
|
||||
type: 'object', required: ['filename'],
|
||||
@@ -1575,8 +1575,8 @@ function buildTools() {
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'create_file',
|
||||
description: 'Create a NEW file with content. If the file already exists, the content is applied as a full-file edit via replace_lines instead. For editing existing files, prefer replace_lines or insert_after directly.',
|
||||
name: 'coder_write_file',
|
||||
description: 'Create a NEW file with content. If the file already exists, the content is applied as a full-file edit via coder_overwrite_lines instead. For editing existing files, prefer coder_overwrite_lines or coder_insert_lines directly.',
|
||||
parameters: {
|
||||
type: 'object', required: ['filename', 'content'],
|
||||
properties: {
|
||||
@@ -1589,8 +1589,8 @@ function buildTools() {
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'replace_lines',
|
||||
description: 'Replace specific lines in an existing file. Use read_file first to see line numbers.',
|
||||
name: 'coder_overwrite_lines',
|
||||
description: 'Replace specific lines in an existing file. Use coder_read_file first to see line numbers.',
|
||||
parameters: {
|
||||
type: 'object', required: ['filename', 'start_line', 'end_line', 'new_content'],
|
||||
properties: {
|
||||
@@ -1605,7 +1605,7 @@ function buildTools() {
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'insert_after',
|
||||
name: 'coder_insert_lines',
|
||||
description: 'Insert new lines after a specific line number. Use 0 to insert at beginning.',
|
||||
parameters: {
|
||||
type: 'object', required: ['filename', 'after_line', 'content'],
|
||||
@@ -1620,7 +1620,7 @@ function buildTools() {
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'delete_lines',
|
||||
name: 'coder_delete_lines',
|
||||
description: 'Delete specific lines from a file.',
|
||||
parameters: {
|
||||
type: 'object', required: ['filename', 'start_line', 'end_line'],
|
||||
@@ -1635,7 +1635,7 @@ function buildTools() {
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'find_replace',
|
||||
name: 'coder_str_replace',
|
||||
description: 'Find exact text in a file and replace it. Good for small text changes.',
|
||||
parameters: {
|
||||
type: 'object', required: ['filename', 'find', 'replace'],
|
||||
@@ -1650,13 +1650,13 @@ function buildTools() {
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'edit_file',
|
||||
description: 'Edit an existing file using SEARCH/REPLACE blocks. PREFERRED tool for making targeted edits — no line numbers needed. ALWAYS read_file first to get exact content. Multiple edits allowed in one call.',
|
||||
name: 'coder_patch_file',
|
||||
description: 'Edit an existing file using SEARCH/REPLACE blocks. PREFERRED tool for making targeted edits — no line numbers needed. ALWAYS coder_read_file first to get exact content. Multiple edits allowed in one call.',
|
||||
parameters: {
|
||||
type: 'object', required: ['filename', 'edits'],
|
||||
properties: {
|
||||
filename: { type: 'string', description: 'File path relative to project root (e.g. "code/snake.py")' },
|
||||
edits: { type: 'string', description: 'One or more SEARCH/REPLACE blocks. Format:\n------- SEARCH\nexact text to find (must match file character-by-character)\n=======\nreplacement text\n+++++++ REPLACE\n\nMultiple blocks are separated by the same pattern. SEARCH text MUST match exactly — copy from read_file output.' },
|
||||
edits: { type: 'string', description: 'One or more SEARCH/REPLACE blocks. Format:\n------- SEARCH\nexact text to find (must match file character-by-character)\n=======\nreplacement text\n+++++++ REPLACE\n\nMultiple blocks are separated by the same pattern. SEARCH text MUST match exactly — copy from coder_read_file output.' },
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -1664,7 +1664,7 @@ function buildTools() {
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'delete_file',
|
||||
name: 'coder_delete_file',
|
||||
description: 'Delete a file from the workspace.',
|
||||
parameters: {
|
||||
type: 'object', required: ['filename'],
|
||||
@@ -1675,7 +1675,7 @@ function buildTools() {
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'move_file',
|
||||
name: 'coder_move_file',
|
||||
description: 'Move or rename a file. Works in both server workspace and client-local mode.',
|
||||
parameters: {
|
||||
type: 'object', required: ['source', 'destination'],
|
||||
@@ -1770,7 +1770,7 @@ function buildTools() {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'shell',
|
||||
description: 'Execute a terminal command and return its output. Use this for running scripts (python, node), CLI tools (pip, npm, git), and any shell command. The command runs in the workspace directory. NEVER use heredoc syntax (<<EOF, <<PY) — write the script to a file first with create_file, then execute it with shell.',
|
||||
description: 'Execute a terminal command and return its output. Use this for running scripts (python, node), CLI tools (pip, npm, git), and any shell command. The command runs in the workspace directory. NEVER use heredoc syntax (<<EOF, <<PY) — write the script to a file first with coder_write_file, then execute it with shell.',
|
||||
parameters: {
|
||||
type: 'object', required: ['command'],
|
||||
properties: {
|
||||
@@ -1995,12 +1995,12 @@ function buildTools() {
|
||||
allowed_tools: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
description: 'Tools this subagent can access. Examples: web_fetch, browser_open, browser_click, read_file, time_now. Use wildcard patterns like "browser_*".',
|
||||
description: 'Tools this subagent can access. Examples: web_fetch, browser_open, browser_click, coder_read_file, time_now. Use wildcard patterns like "browser_*".',
|
||||
},
|
||||
forbidden_tools: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
description: 'Explicit tool blacklist to prevent (e.g., ["run_command", "create_file"])',
|
||||
description: 'Explicit tool blacklist to prevent (e.g., ["run_command", "coder_write_file"])',
|
||||
},
|
||||
system_instructions: {
|
||||
type: 'string',
|
||||
@@ -3151,7 +3151,7 @@ function userRequestedImageEdit(message: string): boolean {
|
||||
|
||||
async function executeTool(name: string, args: any, workspacePath: string, sessionId: string = 'default', sendSSE?: (type: string, data: any) => void, username?: string): Promise<ToolResult> {
|
||||
// Filename inference: if the model forgot to pass filename, use the last one
|
||||
const needsFilename = ['read_file', 'create_file', 'replace_lines', 'insert_after', 'delete_lines', 'find_replace', 'edit_file', 'delete_file'];
|
||||
const needsFilename = ['coder_read_file', 'coder_write_file', 'coder_overwrite_lines', 'coder_insert_lines', 'coder_delete_lines', 'coder_str_replace', 'coder_patch_file', 'coder_delete_file'];
|
||||
if (needsFilename.includes(name)) {
|
||||
// Normalize: secondary AI sometimes returns "path" or "file" instead of "filename"
|
||||
if (!args.filename && !args.name) {
|
||||
@@ -3174,20 +3174,20 @@ async function executeTool(name: string, args: any, workspacePath: string, sessi
|
||||
if (codeAiLocalSessions.has(sessionId)) {
|
||||
const fname = String(args.filename || args.name || '(file)');
|
||||
switch (name) {
|
||||
case 'create_file': return { name, args, result: `Created ${fname}.`, error: false };
|
||||
case 'edit_file': return { name, args, result: `Applied edits to ${fname}.`, error: false };
|
||||
case 'replace_lines': return { name, args, result: `Updated ${fname} (replaced lines ${args.start_line}-${args.end_line}).`, error: false };
|
||||
case 'insert_after': return { name, args, result: `Updated ${fname} (inserted after line ${args.after_line}).`, error: false };
|
||||
case 'delete_lines': return { name, args, result: `Updated ${fname} (deleted lines ${args.start_line}-${args.end_line}).`, error: false };
|
||||
case 'find_replace': return { name, args, result: `Updated ${fname}.`, error: false };
|
||||
case 'delete_file': return { name, args, result: `Deleted ${fname}.`, error: false };
|
||||
case 'move_file': return { name, args, result: `${args.source || fname} → ${args.destination || '(destination)'}`, error: false };
|
||||
case 'read_file': return { name, args, result: `"${fname}"의 내용은 이미 대화 컨텍스트에 줄 번호와 함께 포함되어 있습니다. 컨텍스트의 해당 내용을 그대로 사용하세요. 컨텍스트에 없다면 사용자에게 에디터에서 파일을 열어달라고 요청하세요.`, error: false };
|
||||
case 'list_files':
|
||||
case 'coder_write_file': return { name, args, result: `Created ${fname}.`, error: false };
|
||||
case 'coder_patch_file': return { name, args, result: `Applied edits to ${fname}.`, error: false };
|
||||
case 'coder_overwrite_lines': return { name, args, result: `Updated ${fname} (replaced lines ${args.start_line}-${args.end_line}).`, error: false };
|
||||
case 'coder_insert_lines': return { name, args, result: `Updated ${fname} (inserted after line ${args.after_line}).`, error: false };
|
||||
case 'coder_delete_lines': return { name, args, result: `Updated ${fname} (deleted lines ${args.start_line}-${args.end_line}).`, error: false };
|
||||
case 'coder_str_replace': return { name, args, result: `Updated ${fname}.`, error: false };
|
||||
case 'coder_delete_file': return { name, args, result: `Deleted ${fname}.`, error: false };
|
||||
case 'coder_move_file': return { name, args, result: `${args.source || fname} → ${args.destination || '(destination)'}`, error: false };
|
||||
case 'coder_read_file': return { name, args, result: `"${fname}"의 내용은 이미 대화 컨텍스트에 줄 번호와 함께 포함되어 있습니다. 컨텍스트의 해당 내용을 그대로 사용하세요. 컨텍스트에 없다면 사용자에게 에디터에서 파일을 열어달라고 요청하세요.`, error: false };
|
||||
case 'coder_list_files':
|
||||
case 'search_files': return { name, args, result: `프로젝트 파일과 내용은 이미 대화 컨텍스트의 [파일: ...] 블록에 포함되어 있습니다. 별도 조회 없이 컨텍스트를 참고하세요.`, error: false };
|
||||
// Shell/run tools see the server workspace, not the client's local folder.
|
||||
// Returning a clear message prevents the model from mistaking "file not found
|
||||
// on server" for a failed create_file and re-creating the file server-side.
|
||||
// on server" for a failed coder_write_file and re-creating the file server-side.
|
||||
case 'shell':
|
||||
case 'run_command':
|
||||
case 'bash': return { name, args, result: `[ERROR] 클라이언트 로컬 모드에서는 셸 명령을 사용할 수 없습니다. 파일은 브라우저의 로컬 폴더에 저장됩니다. shell/run_command/bash 도구를 호출하지 마세요.`, error: true };
|
||||
@@ -3196,7 +3196,7 @@ async function executeTool(name: string, args: any, workspacePath: string, sessi
|
||||
|
||||
try {
|
||||
switch (name) {
|
||||
case 'list_files': {
|
||||
case 'coder_list_files': {
|
||||
let dirArg: string = args.directory || '';
|
||||
let dir = dirArg ? path.join(workspacePath, dirArg) : workspacePath;
|
||||
if (!isPathInsideDir(workspacePath, dir)) return { name, args, result: 'Access denied: path escapes workspace', error: true };
|
||||
@@ -3235,7 +3235,7 @@ async function executeTool(name: string, args: any, workspacePath: string, sessi
|
||||
|
||||
// Resolve filename for edit tools: if the raw filename doesn't exist but
|
||||
// the code-directory version does, use that. This lets the model call
|
||||
// replace_lines("file.py") and find the auto-redirected code/file.py.
|
||||
// coder_overwrite_lines("file.py") and find the auto-redirected code/file.py.
|
||||
function resolveCodeFilename(raw: string, wsPath: string): string {
|
||||
if (!raw || raw.includes('/') || raw.includes('\\')) return raw;
|
||||
const direct = path.join(wsPath, raw);
|
||||
@@ -3246,7 +3246,7 @@ async function executeTool(name: string, args: any, workspacePath: string, sessi
|
||||
return raw; // not found anywhere — let the tool report the error
|
||||
}
|
||||
|
||||
case 'read_file': {
|
||||
case 'coder_read_file': {
|
||||
const filename = resolveCodeFilename(args.filename || args.name, workspacePath);
|
||||
const filePath = path.join(workspacePath, filename);
|
||||
if (!isPathInsideDir(workspacePath, filePath)) return { name, args, result: 'Access denied: path escapes workspace', error: true };
|
||||
@@ -3273,7 +3273,7 @@ async function executeTool(name: string, args: any, workspacePath: string, sessi
|
||||
const BINARY_EXTENSIONS = new Set(['.pptx', '.pdf', '.zip', '.tar', '.gz', '.rar', '.7z', '.exe', '.dll', '.so', '.dylib', '.bin', '.dat', '.db', '.sqlite', '.sqlite3', '.woff', '.woff2', '.ttf', '.otf', '.eot', '.mp3', '.mp4', '.avi', '.mov', '.mkv', '.wav', '.flac', '.ogg', '.webm']);
|
||||
if (ext === '.pptx') {
|
||||
// Extract slide text so the model can inspect/edit slide content.
|
||||
// For visual image inspection, use: list_files("<folder>/preview") then read_file each PNG.
|
||||
// For visual image inspection, use: coder_list_files("<folder>/preview") then coder_read_file each PNG.
|
||||
try {
|
||||
const { execFile: execFileCb } = await import('child_process');
|
||||
const pythonCmd = process.platform === 'win32' ? 'python' : 'python3';
|
||||
@@ -3305,7 +3305,7 @@ print(json.dumps({'slides': slides, 'total': len(prs.slides)}, ensure_ascii=Fals
|
||||
const parsed = JSON.parse(result);
|
||||
const lines = [
|
||||
`PPTX: ${filename} (${parsed.total} slides)`,
|
||||
`[To inspect slide images visually: list_files("${path.dirname(filename)}/preview") then read_file each PNG]`,
|
||||
`[To inspect slide images visually: coder_list_files("${path.dirname(filename)}/preview") then coder_read_file each PNG]`,
|
||||
];
|
||||
for (const s of parsed.slides) {
|
||||
lines.push(`\nSlide ${s.slide} [${s.layout}]${s.images.length ? ` 🖼×${s.images.length}` : ''}`);
|
||||
@@ -3314,7 +3314,7 @@ print(json.dumps({'slides': slides, 'total': len(prs.slides)}, ensure_ascii=Fals
|
||||
return { name, args, result: lines.join('\n'), error: false };
|
||||
} catch (e: any) {
|
||||
const stats = fs.statSync(filePath);
|
||||
return { name, args, result: `File "${filename}" is a binary PPTX (${(stats.size / 1024).toFixed(1)} KB). Text extraction failed: ${e.message}\n[To inspect slides visually: list_files("${path.dirname(filename)}/preview") then read_file each PNG]`, error: true };
|
||||
return { name, args, result: `File "${filename}" is a binary PPTX (${(stats.size / 1024).toFixed(1)} KB). Text extraction failed: ${e.message}\n[To inspect slides visually: coder_list_files("${path.dirname(filename)}/preview") then coder_read_file each PNG]`, error: true };
|
||||
}
|
||||
}
|
||||
if (BINARY_EXTENSIONS.has(ext)) {
|
||||
@@ -3322,7 +3322,7 @@ print(json.dumps({'slides': slides, 'total': len(prs.slides)}, ensure_ascii=Fals
|
||||
return { name, args, result: `File "${filename}" is a binary file (${ext}, ${(stats.size / 1024).toFixed(1)} KB) and cannot be read as text. Use other tools or download it via ${filename}.`, error: true };
|
||||
}
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
const MAX_READ_CHARS = 200_000; // ~200KB — prevents single read_file from flooding context
|
||||
const MAX_READ_CHARS = 200_000; // ~200KB — prevents single coder_read_file from flooding context
|
||||
const truncated = content.length > MAX_READ_CHARS;
|
||||
const displayContent = truncated ? content.slice(0, MAX_READ_CHARS) : content;
|
||||
const numbered = displayContent.split('\n').map((line, i) => `${i + 1}: ${line}`).join('\n');
|
||||
@@ -3330,7 +3330,7 @@ print(json.dumps({'slides': slides, 'total': len(prs.slides)}, ensure_ascii=Fals
|
||||
return { name, args, result: `${filename} (${content.split('\n').length} lines):\n${numbered}${truncNote}`, error: false };
|
||||
}
|
||||
|
||||
case 'create_file': {
|
||||
case 'coder_write_file': {
|
||||
const rawFilename = args.filename || args.name;
|
||||
const codeSubDir = (getConfig().getConfig() as any)?.workspace?.codeDir || 'code';
|
||||
const CODE_EXTS = new Set(['py','js','ts','jsx','tsx','c','cpp','cs','java','kt','rs','go','html','css','scss','vue','svelte','sh','bash','sql','swift','dart','lua','rb','php','ex','hs','r','zig','fish']);
|
||||
@@ -3344,8 +3344,8 @@ print(json.dumps({'slides': slides, 'total': len(prs.slides)}, ensure_ascii=Fals
|
||||
if (!isPathInsideDir(workspacePath, filePath)) return { name, args, result: 'Access denied: path escapes workspace', error: true };
|
||||
if (fs.existsSync(filePath)) {
|
||||
// File exists — compute a surgical diff and apply only the changed lines,
|
||||
// then tell the model to use replace_lines/insert_after next time.
|
||||
console.log(`[v2] create_file("${filename}"): file exists, auto-converting to diff edit`);
|
||||
// then tell the model to use coder_overwrite_lines/coder_insert_lines next time.
|
||||
console.log(`[v2] coder_write_file("${filename}"): file exists, auto-converting to diff edit`);
|
||||
const oldLines = fs.readFileSync(filePath, 'utf-8').split('\n');
|
||||
const newLines = (args.content || '').split('\n');
|
||||
// Find common prefix length (unchanged lines at the start)
|
||||
@@ -3365,7 +3365,7 @@ print(json.dumps({'slides': slides, 'total': len(prs.slides)}, ensure_ascii=Fals
|
||||
|
||||
if (startLine > endLine) {
|
||||
// Pure insertion (old had fewer lines in the changed region)
|
||||
// Use insert_after with line just before the insertion point
|
||||
// Use coder_insert_lines with line just before the insertion point
|
||||
const insertAfterLine = startLine - 1;
|
||||
const oldContent = fs.readFileSync(filePath, 'utf-8');
|
||||
const allLines = oldContent.split('\n');
|
||||
@@ -3373,7 +3373,7 @@ print(json.dumps({'slides': slides, 'total': len(prs.slides)}, ensure_ascii=Fals
|
||||
fs.writeFileSync(filePath, allLines.join('\n'), 'utf-8');
|
||||
return {
|
||||
name, args,
|
||||
result: `${filename} updated — inserted ${replacementLines.length} line(s) after line ${insertAfterLine} (auto-converted from create_file). Next time, use insert_after or replace_lines for edits.`,
|
||||
result: `${filename} updated — inserted ${replacementLines.length} line(s) after line ${insertAfterLine} (auto-converted from coder_write_file). Next time, use coder_insert_lines or coder_overwrite_lines for edits.`,
|
||||
error: false,
|
||||
};
|
||||
}
|
||||
@@ -3387,17 +3387,17 @@ print(json.dumps({'slides': slides, 'total': len(prs.slides)}, ensure_ascii=Fals
|
||||
const lineWord = addedCount === removedCount ? `${addedCount} line(s)` : `${removedCount}→${addedCount} line(s)`;
|
||||
return {
|
||||
name, args,
|
||||
result: `${filename} updated — replaced lines ${startLine}-${endLine} (${lineWord}, auto-converted from create_file). Next time, use replace_lines or insert_after for edits.`,
|
||||
result: `${filename} updated — replaced lines ${startLine}-${endLine} (${lineWord}, auto-converted from coder_write_file). Next time, use coder_overwrite_lines or coder_insert_lines for edits.`,
|
||||
error: false,
|
||||
};
|
||||
}
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, args.content || '', 'utf-8');
|
||||
console.log(`[v2] create_file("${filename}"): new file created (${(args.content || '').length} chars)`);
|
||||
console.log(`[v2] coder_write_file("${filename}"): new file created (${(args.content || '').length} chars)`);
|
||||
return { name, args, result: `${filename} created`, error: false };
|
||||
}
|
||||
|
||||
case 'replace_lines': {
|
||||
case 'coder_overwrite_lines': {
|
||||
const filename = resolveCodeFilename(args.filename || args.name, workspacePath);
|
||||
const startLine = Math.max(1, Math.floor(Number(args.start_line) || 1));
|
||||
const endLine = Math.max(startLine, Math.floor(Number(args.end_line) || startLine));
|
||||
@@ -3413,7 +3413,7 @@ print(json.dumps({'slides': slides, 'total': len(prs.slides)}, ensure_ascii=Fals
|
||||
return { name, args, result: `${filename}: replaced lines ${startLine}-${end} (now ${lines.length} lines)`, error: false };
|
||||
}
|
||||
|
||||
case 'insert_after': {
|
||||
case 'coder_insert_lines': {
|
||||
const filename = resolveCodeFilename(args.filename || args.name, workspacePath);
|
||||
const afterLine = Math.max(0, Math.floor(Number(args.after_line) || 0));
|
||||
const content = String(args.content || '').replace(/\\n/g, '\n');
|
||||
@@ -3427,7 +3427,7 @@ print(json.dumps({'slides': slides, 'total': len(prs.slides)}, ensure_ascii=Fals
|
||||
return { name, args, result: `${filename}: inserted after line ${afterLine} (now ${lines.length} lines)`, error: false };
|
||||
}
|
||||
|
||||
case 'delete_lines': {
|
||||
case 'coder_delete_lines': {
|
||||
const filename = resolveCodeFilename(args.filename || args.name, workspacePath);
|
||||
const startLine = Math.max(1, Math.floor(Number(args.start_line) || 1));
|
||||
const endLine = Math.max(startLine, Math.floor(Number(args.end_line) || startLine));
|
||||
@@ -3441,7 +3441,7 @@ print(json.dumps({'slides': slides, 'total': len(prs.slides)}, ensure_ascii=Fals
|
||||
return { name, args, result: `${filename}: deleted lines ${startLine}-${end} (now ${lines.length} lines)`, error: false };
|
||||
}
|
||||
|
||||
case 'find_replace': {
|
||||
case 'coder_str_replace': {
|
||||
const filename = resolveCodeFilename(args.filename || args.name, workspacePath);
|
||||
const find = args.find || '';
|
||||
const replace = args.replace ?? '';
|
||||
@@ -3449,16 +3449,16 @@ print(json.dumps({'slides': slides, 'total': len(prs.slides)}, ensure_ascii=Fals
|
||||
if (!isPathInsideDir(workspacePath, filePath)) return { name, args, result: 'Access denied: path escapes workspace', error: true };
|
||||
if (!fs.existsSync(filePath)) return { name, args, result: `"${filename}" not found`, error: true };
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
if (!content.includes(find)) return { name, args, result: `Text not found. Use read_file to check exact content.`, error: true };
|
||||
if (!content.includes(find)) return { name, args, result: `Text not found. Use coder_read_file to check exact content.`, error: true };
|
||||
fs.writeFileSync(filePath, content.replace(find, replace), 'utf-8');
|
||||
return { name, args, result: `${filename} updated`, error: false };
|
||||
}
|
||||
|
||||
case 'edit_file': {
|
||||
case 'coder_patch_file': {
|
||||
const filename = resolveCodeFilename(args.filename || args.name, workspacePath);
|
||||
const filePath = path.join(workspacePath, filename);
|
||||
if (!isPathInsideDir(workspacePath, filePath)) return { name, args, result: 'Access denied: path escapes workspace', error: true };
|
||||
if (!fs.existsSync(filePath)) return { name, args, result: `"${filename}" not found. Use create_file for new files.`, error: true };
|
||||
if (!fs.existsSync(filePath)) return { name, args, result: `"${filename}" not found. Use coder_write_file for new files.`, error: true };
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
const edits = String(args.edits || '');
|
||||
// Parse SEARCH/REPLACE blocks
|
||||
@@ -3500,7 +3500,7 @@ print(json.dumps({'slides': slides, 'total': len(prs.slides)}, ensure_ascii=Fals
|
||||
fs.writeFileSync(filePath, result, 'utf-8');
|
||||
const msg = `${filename}: ${editCount} edit(s) applied`;
|
||||
if (failedEdits.length > 0) {
|
||||
return { name, args, result: `${msg}. ${failedEdits.length} edit(s) could not find exact match: ${failedEdits.join('; ')}. Use read_file to verify exact content.`, error: false };
|
||||
return { name, args, result: `${msg}. ${failedEdits.length} edit(s) could not find exact match: ${failedEdits.join('; ')}. Use coder_read_file to verify exact content.`, error: false };
|
||||
}
|
||||
return { name, args, result: msg, error: false };
|
||||
}
|
||||
@@ -3508,10 +3508,10 @@ print(json.dumps({'slides': slides, 'total': len(prs.slides)}, ensure_ascii=Fals
|
||||
if (!edits.includes('------- SEARCH') && edits.length > 0) {
|
||||
return { name, args, result: `No SEARCH/REPLACE blocks found. Use the format:\n------- SEARCH\nexact text\n=======\nreplacement\n+++++++ REPLACE`, error: true };
|
||||
}
|
||||
return { name, args, result: `No edits applied. SEARCH text not found in "${filename}". Use read_file to verify exact content, including whitespace and indentation. Failed matches:\n${failedEdits.join('\n')}`, error: true };
|
||||
return { name, args, result: `No edits applied. SEARCH text not found in "${filename}". Use coder_read_file to verify exact content, including whitespace and indentation. Failed matches:\n${failedEdits.join('\n')}`, error: true };
|
||||
}
|
||||
|
||||
case 'delete_file': {
|
||||
case 'coder_delete_file': {
|
||||
const filename = resolveCodeFilename(args.filename || args.name, workspacePath);
|
||||
const filePath = path.join(workspacePath, filename);
|
||||
if (!isPathInsideDir(workspacePath, filePath)) return { name, args, result: 'Access denied: path escapes workspace', error: true };
|
||||
@@ -3520,7 +3520,7 @@ print(json.dumps({'slides': slides, 'total': len(prs.slides)}, ensure_ascii=Fals
|
||||
return { name, args, result: `${filename} deleted`, error: false };
|
||||
}
|
||||
|
||||
case 'move_file': {
|
||||
case 'coder_move_file': {
|
||||
const srcRaw = resolveCodeFilename(args.source || args.from, workspacePath);
|
||||
const dstRaw = resolveCodeFilename(args.destination || args.to, workspacePath);
|
||||
const srcPath = path.join(workspacePath, srcRaw);
|
||||
@@ -4997,35 +4997,35 @@ When making code changes, use tools to edit files — do NOT output code in mark
|
||||
|
||||
CLIENT-LOCAL MODE: Files are stored in the user's local browser folder via the File System Access API — NOT on the server. The shell, run_command, and bash tools are NOT available in this mode and will return an error if called. After creating or editing files, tell the user the files are ready and they can open them in their browser. NEVER call shell/run_command/bash to open, run, or verify files in this mode.` : ''}
|
||||
|
||||
CONTEXT: The current file and all open project files are ALREADY provided in the conversation WITH line numbers (look for "[현재 파일: …]" and "[파일: …]" blocks). Do NOT call read_file for a file whose content is already shown — edit it directly. Only call read_file for a file that is NOT present in the context.
|
||||
CONTEXT: The current file and all open project files are ALREADY provided in the conversation WITH line numbers (look for "[현재 파일: …]" and "[파일: …]" blocks). Do NOT call coder_read_file for a file whose content is already shown — edit it directly. Only call coder_read_file for a file that is NOT present in the context.
|
||||
|
||||
AVAILABLE TOOLS:
|
||||
- read_file(filename): Read file content WITH line numbers. Use ONLY for files not already shown in the context above.
|
||||
- list_files(directory): List files in a directory.
|
||||
- coder_read_file(filename): Read file content WITH line numbers. Use ONLY for files not already shown in the context above.
|
||||
- coder_list_files(directory): List files in a directory.
|
||||
- search_files(pattern): Search file contents with regex.
|
||||
- edit_file(filename, edits): PREFERRED edit tool. Uses SEARCH/REPLACE blocks — no line numbers needed.
|
||||
- create_file(filename, content): Create a new file. For existing files, use edit_file or replace_lines instead.
|
||||
- replace_lines(filename, start_line, end_line, new_content): Replace lines by number (1-based).
|
||||
- insert_after(filename, after_line, content): Insert lines after a line number (0 = beginning).
|
||||
- delete_lines(filename, start_line, end_line): Delete lines by number.
|
||||
- find_replace(filename, find, replace): Find exact text and replace.
|
||||
- move_file(source, destination): Move or rename a file. Parent directories are created automatically. Works in both server workspace and client-local mode.
|
||||
- coder_patch_file(filename, edits): PREFERRED edit tool. Uses SEARCH/REPLACE blocks — no line numbers needed.
|
||||
- coder_write_file(filename, content): Create a new file. For existing files, use coder_patch_file or coder_overwrite_lines instead.
|
||||
- coder_overwrite_lines(filename, start_line, end_line, new_content): Replace lines by number (1-based).
|
||||
- coder_insert_lines(filename, after_line, content): Insert lines after a line number (0 = beginning).
|
||||
- coder_delete_lines(filename, start_line, end_line): Delete lines by number.
|
||||
- coder_str_replace(filename, find, replace): Find exact text and replace.
|
||||
- coder_move_file(source, destination): Move or rename a file. Parent directories are created automatically. Works in both server workspace and client-local mode.
|
||||
- shell(command): Run terminal commands to test code.
|
||||
- python_eval(code): Run Python code.
|
||||
- web_search(query): Search the web.
|
||||
|
||||
edit_file FORMAT (PREFERRED for all edits):
|
||||
coder_patch_file FORMAT (PREFERRED for all edits):
|
||||
------- SEARCH
|
||||
exact text to find (must match the file CHARACTER-BY-CHARACTER including indentation and whitespace)
|
||||
=======
|
||||
replacement text (leave empty between ======= and +++++++ to delete)
|
||||
+++++++ REPLACE
|
||||
|
||||
Multiple edits: list SEARCH/REPLACE blocks in file order. Each SEARCH text must match EXACTLY — copy it verbatim from read_file output. Do NOT modify the marker format.
|
||||
Multiple edits: list SEARCH/REPLACE blocks in file order. Each SEARCH text must match EXACTLY — copy it verbatim from coder_read_file output. Do NOT modify the marker format.
|
||||
|
||||
TOOL USE RULES:
|
||||
- The current/open files are already in context with line numbers — edit them directly without read_file. Only read_file for files NOT shown.
|
||||
- Default to edit_file for most changes. Use create_file only for new files.
|
||||
- The current/open files are already in context with line numbers — edit them directly without coder_read_file. Only coder_read_file for files NOT shown.
|
||||
- Default to coder_patch_file for most changes. Use coder_write_file only for new files.
|
||||
- After editing: briefly state what you changed (1 line). No extended explanations.
|
||||
- NEVER output code in markdown fences (\\\`\\\`\\\`). ALL code changes via tool calls.
|
||||
- NEVER start your message with "Great", "Certainly", "Okay", "Sure", or similar filler.
|
||||
@@ -5111,7 +5111,7 @@ CODE QUALITY:
|
||||
const toolName = call.function?.name || 'unknown';
|
||||
const toolArgs = normalizeToolArgs(call.function?.arguments);
|
||||
if (codeAiBlockedTools.has(toolName)) {
|
||||
const blocked = `[BLOCKED] ${toolName} is not available in Code mode. Use file tools (read_file, edit_file, replace_lines, create_file) or shell instead.`;
|
||||
const blocked = `[BLOCKED] ${toolName} is not available in Code mode. Use file tools (coder_read_file, coder_patch_file, coder_overwrite_lines, coder_write_file) or shell instead.`;
|
||||
allToolResults.push({ name: toolName, args: toolArgs, result: blocked, error: true });
|
||||
try { sendSSE('tool_result', { action: toolName, result: blocked, error: true, stepNum: allToolResults.length }); } catch {}
|
||||
messages.push({ role: 'tool', name: toolName, tool_name: toolName, tool_call_id: toolCallId || undefined, content: blocked });
|
||||
@@ -5163,13 +5163,13 @@ async function handleChat(
|
||||
): Promise<HandleChatResult> {
|
||||
const ollama = getOllamaClient();
|
||||
const isBootStartupTurn = /\bBOOT\.md\b/i.test(String(callerContext || ''));
|
||||
const bootAllowedTools = new Set(['list_files', 'read_file']);
|
||||
const bootAllowedTools = new Set(['coder_list_files', 'coder_read_file']);
|
||||
const configuredWorkspace = getConfig().getWorkspacePath();
|
||||
const sessionWorkspace = getWorkspace(sessionId, username);
|
||||
// Session workspace (user-specific) takes priority over global configured workspace
|
||||
// so that multi-user file tools (read_file, etc.) resolve to the correct user directory.
|
||||
// so that multi-user file tools (coder_read_file, etc.) resolve to the correct user directory.
|
||||
// For background tasks with a known username, auto-resolve to the user workspace so
|
||||
// list_files/read_file don't fall back to the non-existent global workspace directory.
|
||||
// coder_list_files/coder_read_file don't fall back to the non-existent global workspace directory.
|
||||
const userWorkspace = username ? getUserWorkspace(username) : null;
|
||||
const workspacePath = sessionWorkspace || userWorkspace || configuredWorkspace;
|
||||
if (workspacePath && sessionWorkspace !== workspacePath) {
|
||||
@@ -5183,8 +5183,8 @@ async function handleChat(
|
||||
const isTranslateSession = String(sessionId || '').startsWith('translate-');
|
||||
const isProjSession = String(sessionId || '').startsWith('proj-');
|
||||
// ── Code AI session: allow file/shell tools, block inappropriate ones ────
|
||||
// Code AI can now use read_file, create_file, replace_lines, insert_after,
|
||||
// delete_lines, list_files, shell, python_eval, and web_search to directly
|
||||
// Code AI can now use coder_read_file, coder_write_file, coder_overwrite_lines, coder_insert_lines,
|
||||
// coder_delete_lines, coder_list_files, shell, python_eval, and web_search to directly
|
||||
// inspect and fix code. Blocked tools are ones that don't make sense in
|
||||
// an editor context (browser automation, messaging, timers, presentations, etc.)
|
||||
const codeAiBlockedTools = new Set(['start_task', 'task_control', 'write_note', 'browser_open', 'browser_snapshot', 'browser_click', 'browser_fill', 'browser_press_key', 'browser_wait', 'browser_close', 'image_search', 'image_url', 'image_edit', 'image_info', 'image_read', 'weather_search', 'start_timer', 'send_message', 'send_telegram', 'create_presentation', 'edit_presentation', 'spawn_subagent', 'schedule_job', 'parse_schedule_pattern']);
|
||||
@@ -5351,7 +5351,7 @@ async function handleChat(
|
||||
const seenToolCalls = new Set<string>();
|
||||
const cachedReadOnlyToolResults = new Map<string, ToolResult>();
|
||||
const canReplayReadOnlyCall = (toolName: string): boolean =>
|
||||
toolName === 'list_files' || toolName === 'read_file';
|
||||
toolName === 'coder_list_files' || toolName === 'coder_read_file';
|
||||
const loopDetectionEnabled = orchRuntimeCfg?.triggers?.loop_detection !== false;
|
||||
const loopWarningThreshold = 3;
|
||||
const loopCriticalThreshold = 5;
|
||||
@@ -5386,7 +5386,7 @@ async function handleChat(
|
||||
if (recentToolCalls.length > 20) recentToolCalls.shift();
|
||||
const isSnapshotLoop = toolName === 'browser_snapshot';
|
||||
// Pure read tools always return the same result for identical args — block after 1 repeat
|
||||
const isDeterministicRead = ['memory_browse', 'memory_read', 'read_file', 'list_files'].includes(toolName);
|
||||
const isDeterministicRead = ['memory_browse', 'memory_read', 'coder_read_file', 'coder_list_files'].includes(toolName);
|
||||
const blockAt = isSnapshotLoop ? snapshotLoopBlock : isDeterministicRead ? 2 : loopCriticalThreshold;
|
||||
const warnAt = isSnapshotLoop ? snapshotLoopWarn : isDeterministicRead ? 2 : loopWarningThreshold;
|
||||
if (repeats >= blockAt) return { state: 'block', repeats };
|
||||
@@ -5627,7 +5627,7 @@ async function handleChat(
|
||||
ANTI-HALLUCINATION: When a tool returns a result, report EXACTLY what the tool returned — never contradict or ignore tool output. If a tool says "(no rows)", say so. Never invent data, file contents, table names, or command output. If you don't know something, call a tool to find out or say you don't know.
|
||||
IMAGE EDITING RULE: NEVER call image_edit (or any editing tool) when a user uploads a photo without explicitly requesting edits. Uploading a photo is NOT a request to edit it. Only call image_edit when the user's message explicitly asks for an edit (e.g. "수채화로 바꿔줘", "회전해줘"). Violating this rule is a critical error.
|
||||
BROWSER RULE: NEVER call browser_open, browser_snapshot, browser_click, browser_fill, browser_scroll, or any browser_* tool unless the user EXPLICITLY asks to use the browser (e.g. "브라우저로 열어줘", "Chrome으로 봐줘", "사이트 직접 들어가봐"). "열어줘" or "보여줘" alone is NOT a browser request — answer from knowledge, use web_fetch/web_search, or output the relevant URL/embed. Pasting a URL or asking a question is also NOT a browser request.
|
||||
CODE OUTPUT: When writing code in a fenced code block, start with a filename comment on line 1: \`# filename: snake_game.py\` (Python), \`// filename: app.js\` (JS/C), \`<!-- filename: index.html -->\` (HTML). Never repeat code already written in this conversation. For modifications to existing files, use replace_lines or insert_after (not create_file — it only works for NEW files). All code changes are presented as diffs for the user to review before being applied. Write code directly — do not ask for permission.
|
||||
CODE OUTPUT: When writing code in a fenced code block, start with a filename comment on line 1: \`# filename: snake_game.py\` (Python), \`// filename: app.js\` (JS/C), \`<!-- filename: index.html -->\` (HTML). Never repeat code already written in this conversation. For modifications to existing files, use coder_overwrite_lines or coder_insert_lines (not coder_write_file — it only works for NEW files). All code changes are presented as diffs for the user to review before being applied. Write code directly — do not ask for permission.
|
||||
PACKAGE INSTALL: NEVER run pip install, npm install, apt-get, or any package installation command. If a package is missing, just write the code and mention the package name in a comment — let the user decide whether to install it. Do NOT attempt to install packages yourself.
|
||||
OUTPUT FORMAT: When presenting 3+ items (news articles, emails, search results, lists), always use a markdown table or structured bullet list with clear headers. Never dump them as a long paragraph. Example: news → table with columns 제목|요약|출처.${callerContext ? '\n\n' + callerContext : ''}${browserStateCtx}${personalityCtx}${skillsManager.buildPromptContextForUser(username ? getUserWorkspace(username) : null, 16000, message)}`,
|
||||
},
|
||||
@@ -6780,10 +6780,10 @@ RULES:
|
||||
|
||||
// Build reply from actual results — no Ollama, no extra AI call
|
||||
const createdFiles = fileOpToolHistory
|
||||
.filter(h => h.tool === 'create_file' && !h.error)
|
||||
.filter(h => h.tool === 'coder_write_file' && !h.error)
|
||||
.map(h => String(h.args?.filename || h.args?.name || h.args?.path || 'file'));
|
||||
const editedFiles = fileOpToolHistory
|
||||
.filter(h => isFileMutationTool(h.tool) && h.tool !== 'create_file' && !h.error)
|
||||
.filter(h => isFileMutationTool(h.tool) && h.tool !== 'coder_write_file' && !h.error)
|
||||
.map(h => String(h.args?.filename || h.args?.name || h.args?.path || 'file'));
|
||||
const failedOps = fileOpToolHistory.filter(h => h.error);
|
||||
|
||||
@@ -7225,7 +7225,7 @@ RULES:
|
||||
messages.push({
|
||||
role: 'user',
|
||||
content:
|
||||
'Do not stop at an intention statement. Continue now by calling the next SmallClaw tool. Use only available tools (for filesystem use list_files/read_file/create_file/replace_lines/insert_after/delete_lines/find_replace). If a path failed, inspect workspace first and then proceed.',
|
||||
'Do not stop at an intention statement. Continue now by calling the next SmallClaw tool. Use only available tools (for filesystem use coder_list_files/coder_read_file/coder_write_file/coder_overwrite_lines/coder_insert_lines/coder_delete_lines/coder_str_replace). If a path failed, inspect workspace first and then proceed.',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
@@ -7596,7 +7596,7 @@ RULES:
|
||||
}
|
||||
|
||||
if (isBootStartupTurn && !bootAllowedTools.has(toolName)) {
|
||||
const blockMsg = `BOOT mode: "${toolName}" is disabled. Use only list_files and read_file, then provide the startup summary.`;
|
||||
const blockMsg = `BOOT mode: "${toolName}" is disabled. Use only coder_list_files and coder_read_file, then provide the startup summary.`;
|
||||
console.log(`[v2] BOOT TOOL BLOCKED: ${toolName}`);
|
||||
const blockedResult: ToolResult = {
|
||||
name: toolName,
|
||||
@@ -7623,16 +7623,16 @@ RULES:
|
||||
continue;
|
||||
}
|
||||
|
||||
if (toolName === 'create_file') {
|
||||
if (toolName === 'coder_write_file') {
|
||||
const fn = toolArgs.filename || toolArgs.name;
|
||||
if (fn && batchCreatedFiles.has(fn)) {
|
||||
console.log(`[v2] SKIP: duplicate create_file("${fn}") in same batch`);
|
||||
console.log(`[v2] SKIP: duplicate coder_write_file("${fn}") in same batch`);
|
||||
messages.push({
|
||||
role: 'tool',
|
||||
name: toolName,
|
||||
tool_name: toolName,
|
||||
tool_call_id: toolCallId || undefined,
|
||||
content: `${fn} already created in this batch. Use replace_lines to edit.`,
|
||||
content: `${fn} already created in this batch. Use coder_overwrite_lines to edit.`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
@@ -8115,11 +8115,11 @@ RULES:
|
||||
}
|
||||
// Block execution tools in Code AI sessions — the model should output code as text, not run it
|
||||
if (isCodeAiSession && codeAiBlockedTools.has(toolName)) {
|
||||
const blockedResult: ToolResult = { name: toolName, args: toolArgs, result: `[BLOCKED] ${toolName} is not available in Code mode. Use read_file, replace_lines, insert_after, create_file, shell, or web_search instead.`, error: true };
|
||||
const blockedResult: ToolResult = { name: toolName, args: toolArgs, result: `[BLOCKED] ${toolName} is not available in Code mode. Use coder_read_file, coder_overwrite_lines, coder_insert_lines, coder_write_file, shell, or web_search instead.`, error: true };
|
||||
allToolResults.push(blockedResult);
|
||||
sendSSE('tool_result', { action: toolName, result: blockedResult.result, error: true, stepNum: allToolResults.length });
|
||||
messages.push({ role: 'tool', name: toolName, tool_call_id: toolCallId || undefined, content: blockedResult.result });
|
||||
messages.push({ role: 'user', content: `Tool ${toolName} is blocked in Code mode. Use the available file tools (read_file, replace_lines, insert_after, create_file) or shell to accomplish this instead.` });
|
||||
messages.push({ role: 'user', content: `Tool ${toolName} is blocked in Code mode. Use the available file tools (coder_read_file, coder_overwrite_lines, coder_insert_lines, coder_write_file) or shell to accomplish this instead.` });
|
||||
continue;
|
||||
}
|
||||
const toolResult = await executeTool(toolName, toolArgs, workspacePath, sessionId, sendSSE, username);
|
||||
@@ -11372,7 +11372,7 @@ app.post('/api/chat', async (req, res) => {
|
||||
// Code editor chat runs on its own clean tool loop — no multi-agent orchestration.
|
||||
const isCodeAiChat = /^code_ai_/.test(String(sessionId));
|
||||
// Client sends useTools:false for plan/ask phase — honour it so the model doesn't
|
||||
// loop on list_files when it should just generate a text response.
|
||||
// loop on coder_list_files when it should just generate a text response.
|
||||
const codeAiUseTools = useTools !== false;
|
||||
const result = isCodeAiChat
|
||||
? await handleCodeChat(message, sessionId, sendSSE, abortSignal, modelOverride, user?.username, histOverride, codeAiUseTools)
|
||||
|
||||
@@ -141,8 +141,8 @@ export interface OrchestrationLikeConfig {
|
||||
file_ops?: Partial<FileOpSettings>;
|
||||
}
|
||||
|
||||
const CREATE_TOOLS = new Set(['create_file']);
|
||||
const EDIT_TOOLS = new Set(['replace_lines', 'insert_after', 'delete_lines', 'find_replace', 'delete_file']);
|
||||
const CREATE_TOOLS = new Set(['coder_write_file']);
|
||||
const EDIT_TOOLS = new Set(['coder_overwrite_lines', 'coder_insert_lines', 'coder_delete_lines', 'coder_str_replace', 'coder_delete_file']);
|
||||
const MUTATION_TOOLS = new Set([...Array.from(CREATE_TOOLS), ...Array.from(EDIT_TOOLS)]);
|
||||
|
||||
function clampInt(value: any, min: number, max: number, fallback: number): number {
|
||||
@@ -270,7 +270,7 @@ export function estimateFileToolChange(toolName: string, args: any): FileToolEst
|
||||
return { lines_changed: 0, chars_changed: 0, files_touched: 0 };
|
||||
}
|
||||
|
||||
if (name === 'create_file') {
|
||||
if (name === 'coder_write_file') {
|
||||
const content = String(args?.content || '');
|
||||
return {
|
||||
lines_changed: countLines(content),
|
||||
@@ -280,7 +280,7 @@ export function estimateFileToolChange(toolName: string, args: any): FileToolEst
|
||||
};
|
||||
}
|
||||
|
||||
if (name === 'replace_lines') {
|
||||
if (name === 'coder_overwrite_lines') {
|
||||
const newContent = String(args?.new_content || '');
|
||||
return {
|
||||
lines_changed: Math.max(1, countLines(newContent)),
|
||||
@@ -290,7 +290,7 @@ export function estimateFileToolChange(toolName: string, args: any): FileToolEst
|
||||
};
|
||||
}
|
||||
|
||||
if (name === 'insert_after') {
|
||||
if (name === 'coder_insert_lines') {
|
||||
const content = String(args?.content || '');
|
||||
return {
|
||||
lines_changed: Math.max(1, countLines(content)),
|
||||
@@ -300,7 +300,7 @@ export function estimateFileToolChange(toolName: string, args: any): FileToolEst
|
||||
};
|
||||
}
|
||||
|
||||
if (name === 'delete_lines') {
|
||||
if (name === 'coder_delete_lines') {
|
||||
const start = Math.max(1, Math.floor(Number(args?.start_line) || 1));
|
||||
const end = Math.max(start, Math.floor(Number(args?.end_line) || start));
|
||||
return {
|
||||
@@ -311,7 +311,7 @@ export function estimateFileToolChange(toolName: string, args: any): FileToolEst
|
||||
};
|
||||
}
|
||||
|
||||
if (name === 'find_replace') {
|
||||
if (name === 'coder_str_replace') {
|
||||
const find = String(args?.find || '');
|
||||
const replace = String(args?.replace ?? '');
|
||||
return {
|
||||
@@ -343,7 +343,7 @@ export function canPrimaryApplyFileTool(input: {
|
||||
const nextTouched = new Set<string>(input.touched_files);
|
||||
if (target) nextTouched.add(target);
|
||||
|
||||
if (toolName === 'create_file') {
|
||||
if (toolName === 'coder_write_file') {
|
||||
const smallByLines = estimate.lines_changed <= input.settings.primary_create_max_lines;
|
||||
const smallByChars = estimate.chars_changed <= input.settings.primary_create_max_chars;
|
||||
return {
|
||||
@@ -379,7 +379,7 @@ export function shouldVerifyFileTurn(input: FileOpVerificationInput, settings: F
|
||||
reasons: string[];
|
||||
} {
|
||||
const reasons: string[] = [];
|
||||
if (input.had_create && settings.verify_create_always) reasons.push('create_file occurred');
|
||||
if (input.had_create && settings.verify_create_always) reasons.push('coder_write_file occurred');
|
||||
if (input.user_requested_full_template) reasons.push('user requested full page/template/config/layout');
|
||||
if (input.primary_write_lines > settings.verify_large_payload_lines) reasons.push('primary wrote large line payload');
|
||||
if (input.primary_write_chars > settings.verify_large_payload_chars) reasons.push('primary wrote large char payload');
|
||||
|
||||
@@ -357,19 +357,19 @@ export class OrchestrationTriggerState {
|
||||
if (filename) this.touchedFiles.add(filename);
|
||||
|
||||
switch (toolName) {
|
||||
case 'create_file':
|
||||
case 'delete_file':
|
||||
case 'find_replace':
|
||||
case 'coder_write_file':
|
||||
case 'coder_delete_file':
|
||||
case 'coder_str_replace':
|
||||
this.riskyEditOps += 1;
|
||||
break;
|
||||
case 'replace_lines':
|
||||
case 'delete_lines': {
|
||||
case 'coder_overwrite_lines':
|
||||
case 'coder_delete_lines': {
|
||||
const start = Math.max(1, Math.floor(Number(args?.start_line) || 1));
|
||||
const end = Math.max(start, Math.floor(Number(args?.end_line) || start));
|
||||
this.riskyEditOps += Math.max(1, end - start + 1);
|
||||
break;
|
||||
}
|
||||
case 'insert_after': {
|
||||
case 'coder_insert_lines': {
|
||||
const inserted = String(args?.content || '').split('\n').length;
|
||||
this.riskyEditOps += Math.max(1, inserted);
|
||||
break;
|
||||
@@ -507,7 +507,7 @@ Return ONLY a JSON object - no markdown, no explanation:
|
||||
"success_criteria": ["what must be true at the end"],
|
||||
"verification_checklist": ["how to verify quickly"],
|
||||
"search_queries": ["query 1", "query 2"],
|
||||
"tool_sequence": ["read_file(file)", "replace_lines(file,...)"],
|
||||
"tool_sequence": ["coder_read_file(file)", "coder_overwrite_lines(file,...)"],
|
||||
"next_actions": ["immediate next action 1", "action 2"],
|
||||
"stop_doing": ["what to avoid"],
|
||||
"hints": ["small concrete tip"],
|
||||
@@ -718,7 +718,7 @@ Return ONLY JSON:
|
||||
{
|
||||
"strategy": "patch" | "regenerate",
|
||||
"tool_calls": [
|
||||
{ "tool": "create_file|replace_lines|insert_after|delete_lines|find_replace|delete_file|read_file|list_files", "args": {} }
|
||||
{ "tool": "coder_write_file|coder_overwrite_lines|coder_insert_lines|coder_delete_lines|coder_str_replace|coder_delete_file|coder_read_file|coder_list_files", "args": {} }
|
||||
],
|
||||
"estimated_lines_changed": 10,
|
||||
"estimated_chars": 650,
|
||||
@@ -727,13 +727,13 @@ Return ONLY JSON:
|
||||
}
|
||||
|
||||
Arg keys for each tool (use EXACTLY these key names — never use "path" or "file"):
|
||||
- create_file: { "filename": "name.ext", "content": "full file content" }
|
||||
- replace_lines: { "filename": "name.ext", "start_line": N, "end_line": N, "new_content": "replacement" }
|
||||
- insert_after: { "filename": "name.ext", "line": N, "content": "lines to insert" }
|
||||
- delete_lines: { "filename": "name.ext", "start_line": N, "end_line": N }
|
||||
- find_replace: { "filename": "name.ext", "find": "exact text", "replace": "new text" }
|
||||
- delete_file: { "filename": "name.ext" }
|
||||
- read_file: { "filename": "name.ext" }
|
||||
- coder_write_file: { "filename": "name.ext", "content": "full file content" }
|
||||
- coder_overwrite_lines: { "filename": "name.ext", "start_line": N, "end_line": N, "new_content": "replacement" }
|
||||
- coder_insert_lines: { "filename": "name.ext", "line": N, "content": "lines to insert" }
|
||||
- coder_delete_lines: { "filename": "name.ext", "start_line": N, "end_line": N }
|
||||
- coder_str_replace: { "filename": "name.ext", "find": "exact text", "replace": "new text" }
|
||||
- coder_delete_file: { "filename": "name.ext" }
|
||||
- coder_read_file: { "filename": "name.ext" }
|
||||
|
||||
Rules:
|
||||
- Prefer minimal tool calls.
|
||||
@@ -1039,14 +1039,14 @@ function compactToolCalls(values: any, maxItems: number = 8): Array<{ tool: stri
|
||||
if (!Array.isArray(values)) return [];
|
||||
const out: Array<{ tool: string; args: Record<string, any> }> = [];
|
||||
const allowed = new Set([
|
||||
'create_file',
|
||||
'replace_lines',
|
||||
'insert_after',
|
||||
'delete_lines',
|
||||
'find_replace',
|
||||
'delete_file',
|
||||
'read_file',
|
||||
'list_files',
|
||||
'coder_write_file',
|
||||
'coder_overwrite_lines',
|
||||
'coder_insert_lines',
|
||||
'coder_delete_lines',
|
||||
'coder_str_replace',
|
||||
'coder_delete_file',
|
||||
'coder_read_file',
|
||||
'coder_list_files',
|
||||
]);
|
||||
for (const v of values) {
|
||||
if (!v || typeof v !== 'object') continue;
|
||||
@@ -2192,7 +2192,7 @@ Return ONLY JSON:
|
||||
"verdict": "continue" | "skip",
|
||||
"resume_task_id": "uuid of task to resume (only when verdict=continue)",
|
||||
"resume_from_step": 0,
|
||||
"opening_action": "browser_snapshot | desktop_screenshot | read_file | (empty if starting fresh)",
|
||||
"opening_action": "browser_snapshot | desktop_screenshot | coder_read_file | (empty if starting fresh)",
|
||||
"rationale": "short reason for the decision",
|
||||
"plan_mutations": [
|
||||
{ "op": "complete", "step_index": 0, "notes": "what happened" },
|
||||
|
||||
+3
-20
@@ -58,16 +58,6 @@
|
||||
:root:not([data-theme="dark"]) .csb-model-dot { background: var(--brand); box-shadow: 0 0 5px var(--brand); }
|
||||
:root:not([data-theme="dark"]) .csb-model-select-wrap::after { color: var(--brand); }
|
||||
:root:not([data-theme="dark"]) .csb-model-select-wrap select { color: var(--text); }
|
||||
/* 프로젝트 카드 */
|
||||
:root:not([data-theme="dark"]) .csb-project-card {
|
||||
background: var(--panel); border-color: var(--line);
|
||||
}
|
||||
:root:not([data-theme="dark"]) .csb-project-card:focus-within {
|
||||
border-color: var(--brand); box-shadow: 0 0 0 2px rgba(22,104,227,.1);
|
||||
}
|
||||
:root:not([data-theme="dark"]) .csb-project-label { color: var(--brand); }
|
||||
:root:not([data-theme="dark"]) .csb-project-select-wrap::after { color: var(--brand); }
|
||||
:root:not([data-theme="dark"]) .csb-project-select-wrap select { color: var(--text); }
|
||||
/* 새 세션 버튼 */
|
||||
:root:not([data-theme="dark"]) .csb-new-session-btn {
|
||||
background: var(--panel); border-color: var(--line); color: var(--muted);
|
||||
@@ -242,12 +232,6 @@
|
||||
<div style="flex-shrink:0;display:flex;flex-direction:column;overflow:hidden;border-top:1px solid var(--line)">
|
||||
<!-- 모델 선택 -->
|
||||
<div style="display:flex;flex-direction:column;flex:1;overflow-y:auto;padding:10px 12px;gap:8px">
|
||||
<div id="csb-session-project-row" class="csb-project-card" style="display:none">
|
||||
<div class="csb-project-label">📁 프로젝트</div>
|
||||
<div class="csb-project-select-wrap">
|
||||
<select id="csb-session-project-sel" onchange="switchProject(this.value||null)"></select>
|
||||
</div>
|
||||
</div>
|
||||
<button onclick="codeAiClear()" class="csb-new-session-btn">↺ 새 세션</button>
|
||||
<!-- 실행 + AI 전송 -->
|
||||
<div style="display:flex;gap:6px;margin-top:2px">
|
||||
@@ -280,10 +264,11 @@
|
||||
<div style="font-size:10px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;margin-bottom:6px">터미널 시작 경로</div>
|
||||
<div style="display:flex;align-items:center;gap:6px">
|
||||
<input type="text" id="csb-termpath" placeholder="/home/kim/projects/mygame"
|
||||
style="flex:1;font-size:12px;padding:6px 8px;background:var(--panel-2);color:var(--text);border:1px solid var(--line);border-radius:8px;font-family:'IBM Plex Mono',monospace">
|
||||
style="flex:1;font-size:12px;padding:6px 8px;background:var(--panel-2);color:var(--text);border:1px solid var(--line);border-radius:8px;font-family:'IBM Plex Mono',monospace"
|
||||
onkeydown="if(event.key==='Enter')csbTermPathSave()">
|
||||
<button onclick="csbTermPathSave()" style="background:var(--brand);color:#fff;border:none;border-radius:8px;padding:6px 10px;font-size:11px;font-weight:700;cursor:pointer;white-space:nowrap">저장</button>
|
||||
</div>
|
||||
<div style="font-size:10px;color:var(--muted);margin-top:4px">새 터미널 탭 열면 이 경로로 자동 이동</div>
|
||||
<div id="csb-termpath-status" style="font-size:10px;color:var(--muted);margin-top:4px">새 터미널 탭 열면 이 경로로 자동 이동</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style="font-size:10px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;margin-bottom:6px">에디터 폰트</div>
|
||||
@@ -398,8 +383,6 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="code-ai-input-row">
|
||||
<button id="code-ai-mode-btn" onclick="toggleCodeAiMode()" title="Ask: 질문/설명 | Code: 파일 편집"
|
||||
style="flex-shrink:0;align-self:flex-end;background:none;border:1px solid #2a3a5a;border-radius:4px;padding:4px 8px;font-size:11px;cursor:pointer;color:#7aabf0;white-space:nowrap;line-height:1.4">✏️ Code</button>
|
||||
<textarea id="code-ai-input" placeholder="AI에게 질문..." rows="1"
|
||||
oninput="this.style.height='auto';this.style.height=Math.min(this.scrollHeight,200)+'px'"
|
||||
onkeydown="if(event.key==='Enter'&&!event.shiftKey){event.preventDefault();codeAiSend();}"></textarea>
|
||||
|
||||
+137
-890
File diff suppressed because it is too large
Load Diff
+27
-59
@@ -2494,11 +2494,29 @@
|
||||
background: #0a1a10; border-radius: 8px;
|
||||
padding: 5px 10px; max-width: 72%;
|
||||
border: 1px solid #1a3a22;
|
||||
animation: thinking-fade 1.8s ease-in-out infinite;
|
||||
display: flex; align-items: center; gap: 7px;
|
||||
}
|
||||
@keyframes thinking-fade {
|
||||
0%, 100% { opacity: .7; }
|
||||
50% { opacity: 1; }
|
||||
.code-ai-msg.thinking::before {
|
||||
content: '';
|
||||
display: inline-block; flex-shrink: 0;
|
||||
width: 7px; height: 7px; border-radius: 50%;
|
||||
background: #4ade80;
|
||||
animation: thinking-pulse 1s ease-in-out infinite;
|
||||
}
|
||||
@keyframes thinking-pulse {
|
||||
0%, 100% { opacity: 0.25; transform: scale(0.7); }
|
||||
50% { opacity: 1; transform: scale(1.2); }
|
||||
}
|
||||
.code-ai-msg.thinking::after {
|
||||
content: '···';
|
||||
letter-spacing: 0.1em;
|
||||
animation: thinking-dots 1.4s steps(4, end) infinite;
|
||||
}
|
||||
@keyframes thinking-dots {
|
||||
0% { opacity: 0.1; }
|
||||
33% { opacity: 0.5; }
|
||||
66% { opacity: 0.9; }
|
||||
100% { opacity: 0.1; }
|
||||
}
|
||||
.code-ai-msg.system {
|
||||
color: #4b7a5e; font-size: 11px; text-align: center;
|
||||
@@ -2521,18 +2539,18 @@
|
||||
gap: 6px;
|
||||
padding: 4px 10px;
|
||||
margin: 3px 0;
|
||||
border-left: 3px solid var(--brand, #7c3aed);
|
||||
border-left: 3px solid #22c55e;
|
||||
border-radius: 4px;
|
||||
background: var(--panel-2, #1e1e2e);
|
||||
background: #0d1f16;
|
||||
font-size: 11px;
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.code-ai-tool-icon { font-size: 13px; flex-shrink: 0; }
|
||||
.code-ai-tool-name { font-weight: 600; color: var(--text, #e2e8f0); }
|
||||
.code-ai-tool-name { font-weight: 600; color: #c8e6c0; }
|
||||
.code-ai-tool-result {
|
||||
font-size: 11px;
|
||||
color: var(--muted, #94a3b8);
|
||||
color: #6b9e82;
|
||||
margin-left: 4px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
@@ -2660,57 +2678,7 @@
|
||||
background: #1a2a1a;
|
||||
color: #d4ffd4;
|
||||
}
|
||||
.csb-project-card {
|
||||
background: linear-gradient(135deg, #0d1a2a 0%, #111a20 100%);
|
||||
border: 1px solid #1a3a5c;
|
||||
border-radius: 10px;
|
||||
padding: 8px 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
transition: border-color .2s;
|
||||
}
|
||||
.csb-project-card:focus-within {
|
||||
border-color: #60a5fa;
|
||||
box-shadow: 0 0 0 2px rgba(96,165,250,.15);
|
||||
}
|
||||
.csb-project-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
color: #60a5fa;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .06em;
|
||||
}
|
||||
.csb-project-select-wrap {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.csb-project-select-wrap::after {
|
||||
content: '▾';
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
font-size: 10px;
|
||||
color: #60a5fa;
|
||||
pointer-events: none;
|
||||
}
|
||||
.csb-project-select-wrap select {
|
||||
width: 100%;
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
font-size: 12px;
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
font-weight: 600;
|
||||
color: #e8f0fe;
|
||||
cursor: pointer;
|
||||
padding-right: 20px;
|
||||
}
|
||||
|
||||
.csb-new-session-btn {
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
|
||||
Reference in New Issue
Block a user