Code editor: block flip-flop colors, view option buttons, terminal error improvements, live server log bubble

- Add alternating block background colors (ff-block-even/odd) for functions/classes
- Add ¶빈줄숨김 / ▓블록색상 toggle buttons to file panel sidebar (below file tree)
- Fix terminal error send: filter Windows prompt+command lines, only send last Traceback block, strip pygame header noise
- Show live server log bubble during AI tool calls, fade out on completion

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
kim
2026-06-09 13:57:27 +09:00
co-authored by Claude Sonnet 4.6
parent 4ead0750b9
commit 9b558be9f4
5 changed files with 320 additions and 45 deletions
+256 -41
View File
@@ -127,15 +127,16 @@ function initCodeView() {
theme: isDark ? 'vs-dark' : 'vs',
language: 'plaintext',
automaticLayout: true,
fontSize: 12,
lineHeight: 19,
fontFamily: "'IBM Plex Mono', 'Fira Code', 'Courier New', monospace",
fontSize: 14,
lineHeight: 22,
fontFamily: "'D2Coding', 'D2 Coding', monospace",
minimap: { enabled: true },
scrollBeyondLastLine: false,
wordWrap: 'on',
renderWhitespace: 'selection',
smoothScrolling: true,
cursorBlinking: 'smooth',
mouseWheelZoom: true,
tabSize: 2,
});
monacoEditor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, codeSaveFile);
@@ -195,6 +196,8 @@ function switchCodeFile(id) {
}
renderCodeFileTabs();
codeUpdateRunButton();
if (_hideBlankLines) setTimeout(_applyHideBlankLines, 50);
if (_blockFlipFlop) setTimeout(_applyBlockFlipFlop, 60);
}
function closeCodeFile(id, ev) {
@@ -444,11 +447,18 @@ async function _clientLoadProjectFiles() {
// errors after a run and offer one-click "AI에게 전송" from the terminal.
const _termBuffers = {}; // shellId → string[] (last 300 lines)
const _TERM_BUF_MAX = 300;
const _ANSI_RE = /\x1b\[[0-9;]*[mGKHFABCDJnsuhl]/g;
function _stripTerminal(raw) {
return raw
.replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '') // OSC (title 등)
.replace(/\x1b\[[\d;?]*[a-zA-Z]/g, '') // CSI (색상, 커서, private mode)
.replace(/\x1b[^[\]]/g, '') // 기타 ESC 시퀀스
.replace(/[\x00-\x09\x0b-\x1f\x7f]/g, '') // 제어문자 (\n 제외)
.replace(/\r/g, '');
}
function _termBufferAppend(shellId, raw) {
if (!shellId) return;
const text = raw.replace(_ANSI_RE, '').replace(/\r/g, '');
const text = _stripTerminal(raw);
if (!text) return;
const lines = text.split('\n');
if (!_termBuffers[shellId]) _termBuffers[shellId] = [];
@@ -484,25 +494,61 @@ function _termDetectAndShowError(shellId) {
function _showTerminalErrorButton(shellId, errorText) {
const btn = document.getElementById('code-term-ai-error-btn');
if (btn) { btn.style.background = '#dc2626'; btn.style.color = '#fff'; btn.dataset.shellId = shellId; }
if (btn) { btn.style.display = ''; btn.style.background = '#dc2626'; btn.style.color = '#fff'; btn.dataset.shellId = shellId; }
}
function _sendTerminalErrorToAI(errorText) {
const input = document.getElementById('code-ai-input');
if (!input) return;
const trimmed = errorText.split('\n').filter(l => l.trim()).slice(-25).join('\n');
// Filter noise lines
const filtered = errorText.split('\n').filter(l => {
const t = l.trim();
if (!t) return false;
if (/^[A-Za-z]:\\.+>/.test(t)) return false; // Windows 프롬프트 (명령어 포함)
if (/^[~/][^\s]*[$#]\s*/.test(t)) return false; // Unix 프롬프트
if (/^Hello from the pygame community/.test(t)) return false;
if (/^https?:\/\/www\.pygame\.org/.test(t)) return false;
return true;
});
// 마지막 Traceback 블록만 추출
let blockStart = 0;
for (let i = filtered.length - 1; i >= 0; i--) {
if (/Traceback \(most recent call last\)/.test(filtered[i])) {
blockStart = i; break;
}
}
const trimmed = filtered.slice(blockStart).slice(-30).join('\n');
if (!trimmed) return;
input.value = '다음 에러를 고쳐줘. edit_file 도구로 파일을 직접 수정해:\n```\n' + trimmed + '\n```';
input.style.height = '';
input.dispatchEvent(new Event('input'));
// Auto-send
codeAiSend();
}
function _getXtermVisibleText(term, maxLines = 80) {
const buf = term.buffer.active;
const lines = [];
const endRow = buf.baseY + buf.cursorY;
const startRow = Math.max(0, endRow - maxLines);
for (let i = startRow; i <= endRow; i++) {
const line = buf.getLine(i);
if (!line) continue;
const t = line.translateToString(true);
if (t.trim()) lines.push(t);
}
return lines.join('\n');
}
function _sendActiveTermErrorToAI() {
const btn = document.getElementById('code-term-ai-error-btn');
const shellId = btn?.dataset.shellId || _lastErrorShellId;
const buf = (shellId && _termBuffers[shellId]) || Object.values(_termBuffers).at(-1);
const text = buf ? buf.slice(-40).join('\n') : _lastErrorText;
const shellId = btn?.dataset.shellId || _lastErrorShellId || activeCodeTermShellId;
const shell = codeTermShells.find(s => s.id === shellId) || codeTermShells.find(s => s.id === activeCodeTermShellId);
const text = (shell?.term ? _getXtermVisibleText(shell.term) : '')
|| (shellId && _termBuffers[shellId] || Object.values(_termBuffers).at(-1))?.slice(-40).join('\n')
|| _lastErrorText;
if (text) _sendTerminalErrorToAI(text);
if (btn) { btn.style.background = '#1a3a28'; btn.style.color = '#22c55e'; }
}
@@ -559,10 +605,9 @@ function handleCompanionMessage(msg) {
if (msg.type === 'shell_ready') {
if (msg.platform) companionPlatform = msg.platform;
if (msg.home) companionHome = msg.home;
s.term.write('\x1b[32m🖥️ 로컬 셸\x1b[0m\r\n');
_companionCdProject(msg.shellId);
}
else if (msg.type === 'shell_output') s.term.write(msg.data);
else if (msg.type === 'shell_output') { s.term.write(msg.data); _termBufferAppend(s.id, msg.data); }
else if (msg.type === 'shell_exit') { s.term.write(`\r\n\x1b[33m[셸 종료 ${msg.code}]\x1b[0m\r\n`); }
else if (msg.type === 'started') s.term.write(`\r\n\x1b[32m▶ ${msg.filename}\x1b[0m\r\n`);
else if (msg.type === 'output') { s.term.write(msg.data.replace(/(?<!\r)\n/g, '\r\n')); _termBufferAppend(s.id, msg.data); }
@@ -596,15 +641,10 @@ function _companionCdProject(shellId) {
const base = (localTermPath || companionHome || '').replace(/[/\\]$/, '');
if (!base) return;
const target = base + sep + activeProjectName;
const cmd = isWin ? `cd /d "${target}"\r` : `cd ${JSON.stringify(target)}\r`;
const cmd = isWin
? `set "PROMPT=$E[A$P$G" && cd /d "${target}" && cls\r`
: `cd ${JSON.stringify(target)} && clear\r`;
companionWs.send(JSON.stringify({ type: 'input', shellId: sid, data: cmd }));
// After cd completes, clear via xterm API so the prompt is always visible
setTimeout(() => {
const shell = codeTermShells.find(s => s.id === sid);
if (shell?.term) shell.term.clear();
if (companionConnected && companionWs)
companionWs.send(JSON.stringify({ type: 'input', shellId: sid, data: '\r' }));
}, 800);
}
function csbLoadCodeDir() {}
@@ -795,7 +835,7 @@ function toggleCodeTerminal() {
if (codeTermOpen) {
panel.style.display = 'flex';
if (btn) btn.classList.add('active');
if (!codeTermShells.length) { if (companionConnected) addLocalShell(); else addCodeTermShell(); }
if (!codeTermShells.length) { requestAnimationFrame(() => { if (companionConnected) addLocalShell(); else addCodeTermShell(); }); }
else { setTimeout(() => { const s = codeTermShells.find(s => s.id === activeCodeTermShellId); if (s?.fitAddon) s.fitAddon.fit(); if (s?.term) s.term.focus(); }, 50); }
} else {
panel.style.display = 'none';
@@ -2547,6 +2587,9 @@ async function _codeAiSendImpl() {
// 파일 작업 키워드가 포함된 항목이 있으면 실행 단계 목록 → 선택지 아님
const fileOpRe = /edit_file|create_file|move_file|delete_file|replace_lines|insert_after|\.[a-zA-Z]{2,4}(?:\s|$|—|:)/;
if (opts.some(o => fileOpRe.test(o.label))) return [];
if (/수정\s*계획|변경\s*계획|개선\s*계획|구현\s*계획/.test(planText)) return [];
const firstPart = planText.slice(0, planText.length * 0.6);
if (opts.every(o => firstPart.includes(o.label))) return [];
return opts;
})();
@@ -2708,8 +2751,9 @@ async function _codeAiSendImpl() {
const dec = new TextDecoder();
let buf = '', reply = '', _preambleBuf = '', _firstToolSeen = false;
let liveTextEl = null, segReply = '';
thinkEl.textContent = '⏳';
thinkEl.textContent = '🤔 분석 중...';
thinkEl.className = 'code-ai-msg thinking';
const _statusMove = (text) => { thinkEl.textContent = text; thinkEl.style.display = ''; const msgs = document.getElementById('code-ai-msgs'); if (msgs) msgs.appendChild(thinkEl); codeAiScrollBottom(); };
// Live streaming state (mirrored into the editor via codeLiveStreamUpdate)
// Pin the diff target to the file that was active when the user hit send —
// that's the file we attached as "[현재 파일: …]" context, so any returned
@@ -2737,7 +2781,7 @@ async function _codeAiSendImpl() {
else if (ev.type === 'done' && ev.reply) { if (!reply) reply = ev.reply; (liveTextEl || thinkEl).textContent = reply; codeAiScrollBottom(); continue; }
// Handle tool events for live editor update + action cards
else if (ev.type === 'tool_call') {
if (!_firstToolSeen) { _firstToolSeen = true; thinkEl.style.display = 'none'; }
_firstToolSeen = true;
// Close current text bubble before showing tool card
if (liveTextEl) {
if (!segReply.trim()) liveTextEl.remove();
@@ -2746,6 +2790,11 @@ async function _codeAiSendImpl() {
}
const toolName = ev.action || ev.name || '';
const toolArgs = ev.args || {};
const _fname = toolArgs.filename || toolArgs.name || toolArgs.path || '';
const _short = _fname ? _fname.split('/').pop() : '';
const _tIcon = _TOOL_ICONS[toolName] || '🔧';
_statusMove(`${_tIcon} ${toolName}${_short ? ': ' + _short : ''} ...`);
_showLogBubble();
liveState.toolCallCount = (liveState.toolCallCount || 0) + 1;
_codeAiToolAction(toolName, toolArgs, null, null);
// ── Client-side file intercept: apply tool changes to local folder ──
@@ -2765,6 +2814,7 @@ async function _codeAiSendImpl() {
const toolResult = ev.result || '';
const toolError = ev.error || false;
_codeAiToolAction(toolName, null, toolResult, toolError);
_statusMove('💭 다음 단계 분석 중...');
// Refresh file list after successful file-modifying tools
if (!toolError && ['create_file', 'replace_lines', 'insert_after', 'delete_lines', 'find_replace', 'edit_file', 'move_file', 'delete_file'].includes(toolName)) {
codeSbRefresh();
@@ -2772,7 +2822,11 @@ async function _codeAiSendImpl() {
}
if (token) {
reply += token; segReply += token;
if (!liveTextEl) { liveTextEl = codeAiAppend('assistant', ''); liveTextEl.classList.add('streaming'); }
if (!liveTextEl) {
liveTextEl = codeAiAppend('assistant', '');
liveTextEl.classList.add('streaming');
if (_firstToolSeen) _statusMove('💬 응답 작성 중...');
}
liveTextEl.textContent = segReply; codeAiScrollBottom();
if (_firstToolSeen) {
try { codeLiveStreamUpdate(segReply, liveState); } catch(e) { console.error('[codeLiveStream] error:', e); liveTextEl.textContent += '\n⚠️ 에디터 스트리밍 오류: ' + (e.message || e); }
@@ -3138,16 +3192,20 @@ async function _codeAiSendImpl() {
try { updateSessionPanel(); } catch (_) {}
thinkEl.classList.remove('streaming');
if (_firstToolSeen) {
thinkEl.style.display = 'none';
thinkEl.remove();
_hideLogBubble();
if (liveTextEl) { liveTextEl.classList.remove('streaming'); codeAiRenderMarkdown(liveTextEl, segReply); }
const doneEl = codeAiAppend('system', '✅ 완료');
doneEl.style.cssText = 'font-weight:600;color:#4ade80;';
} else {
_hideLogBubble();
thinkEl.className = 'code-ai-msg assistant';
if (!reply) { thinkEl.textContent = '(응답 없음)'; thinkEl.style.display = ''; }
else { codeAiRenderMarkdown(thinkEl, reply); thinkEl.style.display = ''; }
}
} catch(e) {
_hideLogBubble();
if (!thinkEl.isConnected) { const msgs = document.getElementById('code-ai-msgs'); if (msgs) msgs.appendChild(thinkEl); }
thinkEl.style.display = '';
thinkEl.className = 'code-ai-msg thinking';
const errMsg = (e.name === 'AbortError' || (e.message || '').includes('aborted'))
@@ -3162,6 +3220,42 @@ async function _codeAiSendImpl() {
}
}
// ── Live server log bubble ────────────────────────────────────────────────
let _logBubble = null;
let _logPollTimer = null;
function _showLogBubble() {
if (_logBubble) return;
const msgs = document.getElementById('code-ai-messages');
if (!msgs) return;
_logBubble = document.createElement('div');
_logBubble.className = 'code-ai-log-bubble';
_logBubble.innerHTML = '<span class="log-live-dot"></span><span class="log-lines">서버 연결 중...</span>';
msgs.appendChild(_logBubble);
codeAiScrollBottom();
_pollLogBubble();
_logPollTimer = setInterval(_pollLogBubble, 800);
}
async function _pollLogBubble() {
if (!_logBubble) return;
try {
const r = await fetch('/api/logs', { headers: authHeaders({}) });
if (!r.ok) return;
const data = await r.json();
const lines = (data.entries || []).slice(-4).map(e => e.msg.slice(0, 90));
const linesEl = _logBubble.querySelector('.log-lines');
if (linesEl) linesEl.textContent = lines.join('\n') || '...';
codeAiScrollBottom();
} catch {}
}
function _hideLogBubble() {
if (_logPollTimer) { clearInterval(_logPollTimer); _logPollTimer = null; }
const b = _logBubble; _logBubble = null;
if (b) { b.style.opacity = '0'; setTimeout(() => b.remove(), 400); }
}
// ── Tool action cards for Cline-style visibility ──────────────────────────
const _TOOL_ICONS = {
read_file: '📖', list_files: '📂', search_files: '🔍',
@@ -5226,24 +5320,126 @@ function csbWordWrap(checked) {
localStorage.setItem('codeEditorWordWrap', checked ? '1' : '0');
}
function csbFontWeight(val) {
localStorage.setItem('codeEditorFontWeight', val);
if (monacoEditor) monacoEditor.updateOptions({ fontWeight: val });
}
let _hideBlankLines = false;
function csbHideBlankLines(checked) {
_hideBlankLines = !!checked;
localStorage.setItem('codeEditorHideBlank', _hideBlankLines ? '1' : '0');
const btn = document.getElementById('code-opt-hideblank');
if (btn) btn.classList.toggle('active', _hideBlankLines);
_applyHideBlankLines();
}
function _applyHideBlankLines() {
if (!monacoEditor) return;
if (!_hideBlankLines) { try { monacoEditor.setHiddenAreas([]); } catch {} return; }
const model = monacoEditor.getModel();
if (!model) return;
const n = model.getLineCount();
const ranges = [];
let start = -1;
let inBlock = false, blockEnd = '';
for (let i = 1; i <= n; i++) {
const l = model.getLineContent(i).trim();
let hide = false;
if (inBlock) {
hide = true;
if (l.includes(blockEnd)) inBlock = false;
} else if (l === '') {
hide = true;
} else if (/^(\/\/|#|--|;)/.test(l)) {
hide = true;
} else if (l.startsWith('/*')) {
hide = true;
if (!l.slice(2).includes('*/')) { inBlock = true; blockEnd = '*/'; }
} else if (l.startsWith('<!--')) {
hide = true;
if (!l.slice(4).includes('-->')) { inBlock = true; blockEnd = '-->'; }
} else if (l.startsWith('"""') || l.startsWith("'''")) {
const d = l.slice(0, 3);
hide = true;
if (!l.slice(3).includes(d)) { inBlock = true; blockEnd = d; }
}
if (hide) { if (start < 0) start = i; }
else { if (start >= 0) { ranges.push({ startLineNumber: start, startColumn: 1, endLineNumber: i - 1, endColumn: 1 }); start = -1; } }
}
if (start >= 0) ranges.push({ startLineNumber: start, startColumn: 1, endLineNumber: n, endColumn: 1 });
try { monacoEditor.setHiddenAreas(ranges); } catch {}
}
let _blockFlipFlop = false;
let _blockFFDecoIds = [];
function csbBlockFlipFlop(checked) {
_blockFlipFlop = !!checked;
localStorage.setItem('codeEditorBlockFF', _blockFlipFlop ? '1' : '0');
const btn = document.getElementById('code-opt-blockff');
if (btn) btn.classList.toggle('active', _blockFlipFlop);
_applyBlockFlipFlop();
}
function _applyBlockFlipFlop() {
if (!monacoEditor) return;
const model = monacoEditor.getModel();
if (!model) return;
_blockFFDecoIds = monacoEditor.deltaDecorations(_blockFFDecoIds, []);
if (!_blockFlipFlop) return;
const n = model.getLineCount();
const decos = [];
// Detect top-level function/class/def declarations
const declRe = /^(export\s+)?(default\s+)?(async\s+)?(function\b|class\b|def\b|async\s+def\b)|^(const|let|var)\s+\w+\s*=\s*(async\s+)?(function|\(|[A-Za-z_$]\w*\s*=>)/;
const boundaries = [];
for (let i = 1; i <= n; i++) {
const line = model.getLineContent(i);
if (line.match(/^(\s*)/)[1].length === 0 && declRe.test(line.trim())) {
boundaries.push(i);
}
}
const ffCls = (idx) => idx % 2 === 0 ? 'ff-block-even' : 'ff-block-odd';
const ffRange = (from, to, idx) => ({ range: { startLineNumber: from, startColumn: 1, endLineNumber: to, endColumn: 1 }, options: { isWholeLine: true, className: ffCls(idx) } });
if (boundaries.length >= 2) {
// Declaration-based: alternate between declaration boundaries
for (let b = 0; b < boundaries.length; b++) {
const from = boundaries[b];
const to = b + 1 < boundaries.length ? boundaries[b + 1] - 1 : n;
decos.push(ffRange(from, to, b));
}
} else {
// Fallback: blank-line-separated groups
let phase = 0, blockStart = -1;
for (let i = 1; i <= n; i++) {
const blank = model.getLineContent(i).trim() === '';
if (!blank && blockStart < 0) { blockStart = i; }
else if (blank && blockStart >= 0) {
decos.push(ffRange(blockStart, i - 1, phase));
blockStart = -1; phase++;
}
}
if (blockStart >= 0) decos.push(ffRange(blockStart, n, phase));
}
_blockFFDecoIds = monacoEditor.deltaDecorations([], decos);
}
const CODE_THEME_GROUPS = [
{ icon: '🌙', label: '다크', themes: [
{ v: 'vs-dark', label: 'VS Dark', bg: '#1e1e1e' },
{ v: 'one-dark', label: 'One Dark', bg: '#282c34' },
{ v: 'nord', label: 'Nord', bg: '#2e3440' },
{ v: 'github-dark', label: 'GitHub Dark', bg: '#0d1117' },
{ v: 'tomorrow-night', label: 'Tomorrow Night',bg: '#1d1f21' },
{ v: 'hc-black', label: 'High Contrast', bg: '#000000' },
{ v: 'github-dark', label: 'GitHub Dark', bg: '#0d1117' },
{ v: 'one-dark', label: 'One Dark', bg: '#282c34' },
{ v: 'dracula', label: 'Dracula', bg: '#282a36' },
{ v: 'nord', label: 'Nord', bg: '#2e3440' },
{ v: 'vs-dark', label: 'VS Dark', bg: '#1e1e1e' },
{ v: 'monokai', label: 'Monokai', bg: '#272822' },
]},
{ icon: '☀️', label: '라이트', themes: [
{ v: 'vs', label: 'VS Light', bg: '#ffffff' },
{ v: 'github-light', label: 'GitHub Light',bg: '#f6f8fa' },
]},
{ icon: '🌿', label: '웜', themes: [
{ v: 'monokai', label: 'Monokai', bg: '#272822' },
{ v: 'dracula', label: 'Dracula', bg: '#282a36' },
{ v: 'solarized-dark', label: 'Solarized Dark', bg: '#002b36' },
{ v: 'solarized-light', label: 'Solarized Light',bg: '#fdf6e3' },
{ v: 'github-light', label: 'GitHub Light', bg: '#f6f8fa' },
{ v: 'vs', label: 'VS Light', bg: '#ffffff' },
]},
];
const CODE_THEMES = CODE_THEME_GROUPS.flatMap(g => g.themes);
@@ -5353,6 +5549,11 @@ function csbMinimap(checked) {
localStorage.setItem('codeEditorMinimap', checked ? '1' : '0');
}
function csbFontFamily(val) {
localStorage.setItem('codeEditorFontFamily', val);
if (monacoEditor) monacoEditor.updateOptions({ fontFamily: val === 'monospace' ? 'monospace' : `'${val}', monospace` });
}
// Restore editor settings from localStorage
function csbRestoreSettings() {
const fs = localStorage.getItem('codeEditorFontSize');
@@ -5360,16 +5561,30 @@ function csbRestoreSettings() {
const th = localStorage.getItem('codeEditorTheme');
const ts = localStorage.getItem('codeEditorTabSize');
const mm = localStorage.getItem('codeEditorMinimap');
const ff = localStorage.getItem('codeEditorFontFamily') || 'D2Coding';
const fw = localStorage.getItem('codeEditorFontWeight') || '400';
const hb = localStorage.getItem('codeEditorHideBlank');
const bff = localStorage.getItem('codeEditorBlockFF');
if (fs) { document.getElementById('csb-fontsize').value = fs; document.getElementById('csb-fontsize-label').textContent = fs; }
if (ww !== null) document.getElementById('csb-wordwrap').checked = ww === '1';
if (ts) document.getElementById('csb-tabsize').value = ts;
if (mm !== null) document.getElementById('csb-minimap').checked = mm === '1';
const ffEl = document.getElementById('csb-fontfamily'); if (ffEl) ffEl.value = ff;
const fwEl = document.getElementById('csb-fontweight'); if (fwEl) fwEl.value = fw;
_hideBlankLines = hb === '1';
_blockFlipFlop = bff === '1';
const hbBtn = document.getElementById('code-opt-hideblank'); if (hbBtn) hbBtn.classList.toggle('active', _hideBlankLines);
const bffBtn = document.getElementById('code-opt-blockff'); if (bffBtn) bffBtn.classList.toggle('active', _blockFlipFlop);
if (monacoEditor) monacoEditor.updateOptions({
fontSize: fs ? parseInt(fs) : 12,
fontSize: fs ? parseInt(fs) : 14,
wordWrap: ww === '0' ? 'off' : 'on',
tabSize: ts ? parseInt(ts) : 2,
minimap: { enabled: mm !== '0' },
fontFamily: ff === 'monospace' ? 'monospace' : `'${ff}', monospace`,
fontWeight: fw,
});
if (_hideBlankLines) _applyHideBlankLines();
if (_blockFlipFlop) _applyBlockFlipFlop();
const activeTheme = th || (document.documentElement.getAttribute('data-theme') !== 'light' ? 'vs-dark' : 'vs');
if (window.monaco) { defineCodeThemes(); monaco.editor.setTheme(activeTheme); }
renderThemeGrid();