mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
chore: version packages
This commit is contained in:
@@ -1,5 +1,17 @@
|
||||
# aicodeman
|
||||
|
||||
## 0.5.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Make buffer size limits configurable via CODEMAN\_\* environment variables (MAX_TERMINAL_BUFFER, TRIM_TERMINAL_TO, MAX_TEXT_OUTPUT, TRIM_TEXT_TO, MAX_MESSAGES), falling back to existing defaults. Allows users with fewer sessions or more RAM to tune buffer sizes without patching source.
|
||||
|
||||
Fix duplicate terminal output on tab switch to busy sessions by clearing the terminal before writing the new buffer.
|
||||
|
||||
Fix stale Ink CUP frames after tab switch by sending Ctrl+L to force a clean redraw.
|
||||
|
||||
Fix mobile CJK input handling: resolve textarea positioning, terminal flicker during composition, and layout overflow on small screens. Improve CJK composition lifecycle with better event handling and fallback flush timers.
|
||||
|
||||
## 0.5.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -52,7 +52,7 @@ When user says "COM":
|
||||
4. **Sync CLAUDE.md version**: Update the `**Version**` line below to match the new version from `package.json`
|
||||
5. **Commit and deploy**: `git add -A && git commit -m "chore: version packages" && git push && npm run build && systemctl --user restart codeman-web`
|
||||
|
||||
**Version**: 0.5.1 (must match `package.json`)
|
||||
**Version**: 0.5.2 (must match `package.json`)
|
||||
|
||||
## Project Overview
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "0.5.1",
|
||||
"version": "0.5.2",
|
||||
"description": "The missing control plane for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
|
||||
+223
-152
@@ -64,7 +64,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
const unicode11Addon = new Unicode11Addon.Unicode11Addon();
|
||||
this.terminal.loadAddon(unicode11Addon);
|
||||
this.terminal.unicode.activeVersion = '11';
|
||||
} catch (_e) { /* Unicode11 addon failed — default Unicode handling used */ }
|
||||
} catch (_e) {
|
||||
/* Unicode11 addon failed — default Unicode handling used */
|
||||
}
|
||||
}
|
||||
|
||||
const container = document.getElementById('terminalContainer');
|
||||
@@ -115,8 +117,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
// On mobile Safari, delay initial fit() to allow layout to settle
|
||||
// This prevents 0-column terminals caused by fit() running before container is sized
|
||||
const isMobileSafari = MobileDetection.getDeviceType() === 'mobile' &&
|
||||
document.body.classList.contains('safari-browser');
|
||||
const isMobileSafari =
|
||||
MobileDetection.getDeviceType() === 'mobile' && document.body.classList.contains('safari-browser');
|
||||
if (isMobileSafari) {
|
||||
// Wait for layout, then fit multiple times to ensure proper sizing
|
||||
requestAnimationFrame(() => {
|
||||
@@ -133,11 +135,15 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
// Always use mouse wheel for terminal scrollback, never forward to application.
|
||||
// Prevents Claude's Ink UI (plan mode selector) from capturing scroll as option navigation.
|
||||
container.addEventListener('wheel', (ev) => {
|
||||
ev.preventDefault();
|
||||
const lines = Math.round(ev.deltaY / 25) || (ev.deltaY > 0 ? 1 : -1);
|
||||
this.terminal.scrollLines(lines);
|
||||
}, { passive: false });
|
||||
container.addEventListener(
|
||||
'wheel',
|
||||
(ev) => {
|
||||
ev.preventDefault();
|
||||
const lines = Math.round(ev.deltaY / 25) || (ev.deltaY > 0 ? 1 : -1);
|
||||
this.terminal.scrollLines(lines);
|
||||
},
|
||||
{ passive: false }
|
||||
);
|
||||
|
||||
// Touch scrolling — use terminal.scrollLines() for all devices.
|
||||
// xterm.js DOM renderer doesn't populate xterm-viewport's scroll area,
|
||||
@@ -145,8 +151,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
// has nothing to scroll. Instead, convert touch deltas into scrollLines()
|
||||
// calls, matching the wheel handler above.
|
||||
{
|
||||
const cellHeight = () =>
|
||||
this.terminal._core?._renderService?.dimensions?.css?.cell?.height || 13;
|
||||
const cellHeight = () => this.terminal._core?._renderService?.dimensions?.css?.cell?.height || 13;
|
||||
let touchLastY = 0;
|
||||
let velocity = 0;
|
||||
let lastTime = 0;
|
||||
@@ -174,55 +179,65 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Accumulate sub-line pixel deltas so slow swipes still scroll
|
||||
let pixelAccum = 0;
|
||||
|
||||
let didScroll = false; // track whether touchmove fired (tap vs scroll)
|
||||
container.addEventListener('touchstart', (ev) => {
|
||||
if (ev.touches.length === 1) {
|
||||
touchLastY = ev.touches[0].clientY;
|
||||
container.addEventListener(
|
||||
'touchstart',
|
||||
(ev) => {
|
||||
if (ev.touches.length === 1) {
|
||||
touchLastY = ev.touches[0].clientY;
|
||||
velocity = 0;
|
||||
pixelAccum = 0;
|
||||
isTouching = true;
|
||||
lastTime = 0;
|
||||
if (scrollFrame) {
|
||||
cancelAnimationFrame(scrollFrame);
|
||||
scrollFrame = null;
|
||||
}
|
||||
}
|
||||
},
|
||||
{ passive: true }
|
||||
);
|
||||
|
||||
container.addEventListener(
|
||||
'touchmove',
|
||||
(ev) => {
|
||||
if (ev.touches.length === 1 && isTouching) {
|
||||
const touchY = ev.touches[0].clientY;
|
||||
const delta = touchLastY - touchY; // positive = scroll down
|
||||
pixelAccum += delta;
|
||||
velocity = delta * 1.2;
|
||||
touchLastY = touchY;
|
||||
// Convert accumulated pixels to whole lines
|
||||
const ch = cellHeight();
|
||||
const lines = Math.trunc(pixelAccum / ch);
|
||||
if (lines !== 0) {
|
||||
this.terminal.scrollLines(lines);
|
||||
pixelAccum -= lines * ch;
|
||||
}
|
||||
}
|
||||
},
|
||||
{ passive: true }
|
||||
);
|
||||
|
||||
container.addEventListener(
|
||||
'touchend',
|
||||
() => {
|
||||
isTouching = false;
|
||||
if (!scrollFrame && Math.abs(velocity) > 0.3) {
|
||||
scrollFrame = requestAnimationFrame(scrollLoop);
|
||||
}
|
||||
},
|
||||
{ passive: true }
|
||||
);
|
||||
|
||||
container.addEventListener(
|
||||
'touchcancel',
|
||||
() => {
|
||||
isTouching = false;
|
||||
velocity = 0;
|
||||
pixelAccum = 0;
|
||||
isTouching = true;
|
||||
didScroll = false;
|
||||
lastTime = 0;
|
||||
if (scrollFrame) { cancelAnimationFrame(scrollFrame); scrollFrame = null; }
|
||||
}
|
||||
}, { passive: true });
|
||||
|
||||
container.addEventListener('touchmove', (ev) => {
|
||||
if (ev.touches.length === 1 && isTouching) {
|
||||
didScroll = true;
|
||||
const touchY = ev.touches[0].clientY;
|
||||
const delta = touchLastY - touchY; // positive = scroll down
|
||||
pixelAccum += delta;
|
||||
velocity = delta * 1.2;
|
||||
touchLastY = touchY;
|
||||
// Convert accumulated pixels to whole lines
|
||||
const ch = cellHeight();
|
||||
const lines = Math.trunc(pixelAccum / ch);
|
||||
if (lines !== 0) {
|
||||
this.terminal.scrollLines(lines);
|
||||
pixelAccum -= lines * ch;
|
||||
}
|
||||
}
|
||||
}, { passive: true });
|
||||
|
||||
container.addEventListener('touchend', () => {
|
||||
isTouching = false;
|
||||
if (!scrollFrame && Math.abs(velocity) > 0.3) {
|
||||
scrollFrame = requestAnimationFrame(scrollLoop);
|
||||
}
|
||||
// Tap (no scroll): refocus xterm's hidden textarea so keyboard input
|
||||
// routes back to the terminal. Without this, a tap on the terminal area
|
||||
// consumes the touch event but xterm's textarea never regains focus.
|
||||
if (!didScroll && this.terminal) {
|
||||
this.terminal.focus();
|
||||
}
|
||||
}, { passive: true });
|
||||
|
||||
container.addEventListener('touchcancel', () => {
|
||||
isTouching = false;
|
||||
velocity = 0;
|
||||
pixelAccum = 0;
|
||||
}, { passive: true });
|
||||
},
|
||||
{ passive: true }
|
||||
);
|
||||
}
|
||||
|
||||
// Welcome message
|
||||
@@ -269,6 +284,22 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
this.flushFlickerBuffer();
|
||||
}
|
||||
// Clear viewport + scrollback for Ink-based sessions before sending SIGWINCH.
|
||||
// fitAddon.fit() reflows content: lines at old width may wrap to more rows,
|
||||
// pushing overflow into scrollback. Ink's cursor-up count is based on the
|
||||
// pre-reflow line count, so ghost renders accumulate in scrollback.
|
||||
// Fix: \x1b[3J (Erase Saved Lines) clears scrollback reflow debris,
|
||||
// then \x1b[H\x1b[2J clears the viewport for a clean Ink redraw.
|
||||
const activeResizeSession = this.activeSessionId ? this.sessions.get(this.activeSessionId) : null;
|
||||
if (
|
||||
activeResizeSession &&
|
||||
activeResizeSession.mode !== 'shell' &&
|
||||
!activeResizeSession._ended &&
|
||||
this.terminal &&
|
||||
this.isTerminalAtBottom()
|
||||
) {
|
||||
this.terminal.write('\x1b[3J\x1b[H\x1b[2J');
|
||||
}
|
||||
// Skip server resize while mobile keyboard is visible — sending SIGWINCH
|
||||
// causes Ink to re-render at the new row count, garbling terminal output.
|
||||
// Local fit() still runs so xterm knows the viewport size for scrolling.
|
||||
@@ -279,27 +310,12 @@ Object.assign(CodemanApp.prototype, {
|
||||
const cols = dims ? Math.max(dims.cols, MIN_COLS) : MIN_COLS;
|
||||
const rows = dims ? Math.max(dims.rows, MIN_ROWS) : MIN_ROWS;
|
||||
// Only send resize if dimensions actually changed
|
||||
if (!this._lastResizeDims ||
|
||||
cols !== this._lastResizeDims.cols ||
|
||||
rows !== this._lastResizeDims.rows) {
|
||||
// Clear viewport + scrollback ONLY when dimensions actually change.
|
||||
// fitAddon.fit() reflows content: lines at old width may wrap to more rows,
|
||||
// pushing overflow into scrollback. Ink's cursor-up count is based on the
|
||||
// pre-reflow line count, so ghost renders accumulate in scrollback.
|
||||
// Fix: \x1b[3J (Erase Saved Lines) clears scrollback reflow debris,
|
||||
// then \x1b[H\x1b[2J clears the viewport for a clean Ink redraw.
|
||||
// IMPORTANT: Only clear when we're actually sending SIGWINCH (dims changed).
|
||||
// Clearing without a subsequent Ink redraw leaves the terminal blank.
|
||||
const activeResizeSession = this.activeSessionId ? this.sessions.get(this.activeSessionId) : null;
|
||||
if (activeResizeSession && activeResizeSession.mode !== 'shell' && !activeResizeSession._ended
|
||||
&& this.terminal && this.isTerminalAtBottom()) {
|
||||
this.terminal.write('\x1b[3J\x1b[H\x1b[2J');
|
||||
}
|
||||
if (!this._lastResizeDims || cols !== this._lastResizeDims.cols || rows !== this._lastResizeDims.rows) {
|
||||
this._lastResizeDims = { cols, rows };
|
||||
fetch(`/api/sessions/${this.activeSessionId}/resize`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ cols, rows })
|
||||
body: JSON.stringify({ cols, rows }),
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
@@ -453,7 +469,11 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (p) {
|
||||
const buf = this.terminal.buffer.active;
|
||||
const line = buf.getLine(buf.viewportY + p.row);
|
||||
if (line) baseText = line.translateToString(true).slice(p.col + 2).trimEnd();
|
||||
if (line)
|
||||
baseText = line
|
||||
.translateToString(true)
|
||||
.slice(p.col + 2)
|
||||
.trimEnd();
|
||||
}
|
||||
} catch {}
|
||||
this._tabCompletionBaseText = baseText;
|
||||
@@ -468,7 +488,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
const selfTab = this;
|
||||
this._tabCompletionFallback = setTimeout(() => {
|
||||
selfTab._tabCompletionFallback = null;
|
||||
if (!selfTab._tabCompletionSessionId || selfTab._tabCompletionSessionId !== selfTab.activeSessionId) return;
|
||||
if (!selfTab._tabCompletionSessionId || selfTab._tabCompletionSessionId !== selfTab.activeSessionId)
|
||||
return;
|
||||
const ov = selfTab._localEchoOverlay;
|
||||
if (!ov || ov.pendingText) return;
|
||||
selfTab.terminal.write('', () => {
|
||||
@@ -567,7 +588,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
|
||||
const buffer = self.terminal.buffer.active;
|
||||
const line = buffer.getLine(bufferLineNumber);
|
||||
// provideLinks passes 1-based line number, getLine expects 0-based
|
||||
const line = buffer.getLine(bufferLineNumber - 1);
|
||||
|
||||
if (!line) {
|
||||
callback(undefined);
|
||||
@@ -584,12 +606,37 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
const links = [];
|
||||
|
||||
// Pattern 0: URLs (https://, http://) — matched first so they take priority
|
||||
const urlPattern = /https?:\/\/[^\s"'<>|;&)\]\x00-\x1f]+/g;
|
||||
|
||||
const addUrlLink = (url, matchIndex) => {
|
||||
// Strip trailing punctuation that's likely not part of the URL
|
||||
const cleaned = url.replace(/[.,;:!?)]+$/, '');
|
||||
const startCol = lineText.indexOf(cleaned, matchIndex);
|
||||
if (startCol === -1) return;
|
||||
|
||||
if (links.some((l) => l.range.start.x === startCol + 1)) return;
|
||||
|
||||
links.push({
|
||||
text: cleaned,
|
||||
range: {
|
||||
start: { x: startCol + 1, y: bufferLineNumber },
|
||||
end: { x: startCol + cleaned.length + 1, y: bufferLineNumber },
|
||||
},
|
||||
decorations: { pointerCursor: true, underline: true },
|
||||
activate(_event, text) {
|
||||
window.open(text, '_blank', 'noopener,noreferrer');
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// Pattern 1: Commands with file paths (tail -f, cat, head, grep pattern, etc.)
|
||||
// Handles: tail -f /path, grep pattern /path, cat -n /path
|
||||
const cmdPattern = /(tail|cat|head|less|grep|watch|vim|nano)\s+(?:[^\s\/]*\s+)*(\/[^\s"'<>|;&\n\x00-\x1f]+)/g;
|
||||
|
||||
// Pattern 2: Paths with common extensions
|
||||
const extPattern = /(\/(?:home|tmp|var|etc|opt)[^\s"'<>|;&\n\x00-\x1f]*\.(?:log|txt|json|md|yaml|yml|csv|xml|sh|py|ts|js))\b/g;
|
||||
const extPattern =
|
||||
/(\/(?:home|tmp|var|etc|opt)[^\s"'<>|;&\n\x00-\x1f]*\.(?:log|txt|json|md|yaml|yml|csv|xml|sh|py|ts|js))\b/g;
|
||||
|
||||
// Pattern 3: Bash() tool output
|
||||
const bashPattern = /Bash\([^)]*?(\/(?:home|tmp|var|etc|opt)[^\s"'<>|;&\)\n\x00-\x1f]+)/g;
|
||||
@@ -599,27 +646,32 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (startCol === -1) return;
|
||||
|
||||
// Skip if already have link at this position
|
||||
if (links.some(l => l.range.start.x === startCol + 1)) return;
|
||||
if (links.some((l) => l.range.start.x === startCol + 1)) return;
|
||||
|
||||
links.push({
|
||||
text: filePath,
|
||||
range: {
|
||||
start: { x: startCol + 1, y: bufferLineNumber }, // 1-based
|
||||
end: { x: startCol + filePath.length + 1, y: bufferLineNumber }
|
||||
start: { x: startCol + 1, y: bufferLineNumber }, // 1-based
|
||||
end: { x: startCol + filePath.length + 1, y: bufferLineNumber },
|
||||
},
|
||||
decorations: {
|
||||
pointerCursor: true,
|
||||
underline: true
|
||||
underline: true,
|
||||
},
|
||||
activate(event, text) {
|
||||
self.openLogViewerWindow(text, self.activeSessionId);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// Match all patterns
|
||||
// Match all patterns — URLs first so they take priority
|
||||
let match;
|
||||
|
||||
urlPattern.lastIndex = 0;
|
||||
while ((match = urlPattern.exec(lineText)) !== null) {
|
||||
addUrlLink(match[0], match.index);
|
||||
}
|
||||
|
||||
cmdPattern.lastIndex = 0;
|
||||
while ((match = cmdPattern.exec(lineText)) !== null) {
|
||||
addLink(match[2], match.index);
|
||||
@@ -636,10 +688,13 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
|
||||
if (links.length > 0) {
|
||||
console.debug('[LinkProvider] Found links:', links.map(l => l.text));
|
||||
console.debug(
|
||||
'[LinkProvider] Found links:',
|
||||
links.map((l) => l.text)
|
||||
);
|
||||
}
|
||||
callback(links.length > 0 ? links : undefined);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
console.log('[LinkProvider] File path link provider registered');
|
||||
@@ -705,12 +760,17 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Build DOM safely (no innerHTML with user data)
|
||||
list.replaceChildren();
|
||||
for (const s of display) {
|
||||
const size = s.sizeBytes < 1024 ? `${s.sizeBytes}B`
|
||||
: s.sizeBytes < 1048576 ? `${(s.sizeBytes / 1024).toFixed(0)}K`
|
||||
: `${(s.sizeBytes / 1048576).toFixed(1)}M`;
|
||||
const size =
|
||||
s.sizeBytes < 1024
|
||||
? `${s.sizeBytes}B`
|
||||
: s.sizeBytes < 1048576
|
||||
? `${(s.sizeBytes / 1024).toFixed(0)}K`
|
||||
: `${(s.sizeBytes / 1048576).toFixed(1)}M`;
|
||||
const date = new Date(s.lastModified);
|
||||
const timeStr = date.toLocaleDateString('en', { month: 'short', day: 'numeric' })
|
||||
+ ' ' + date.toLocaleTimeString('en', { hour: '2-digit', minute: '2-digit', hour12: false });
|
||||
const timeStr =
|
||||
date.toLocaleDateString('en', { month: 'short', day: 'numeric' }) +
|
||||
' ' +
|
||||
date.toLocaleTimeString('en', { hour: '2-digit', minute: '2-digit', hour12: false });
|
||||
const shortDir = s.workingDir.replace(/^\/home\/[^/]+\//, '~/');
|
||||
|
||||
const item = document.createElement('div');
|
||||
@@ -773,7 +833,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
const createRes = await fetch('/api/sessions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ workingDir, name, resumeSessionId: sessionId })
|
||||
body: JSON.stringify({ workingDir, name, resumeSessionId: sessionId }),
|
||||
});
|
||||
const createData = await createRes.json();
|
||||
if (!createData.success) throw new Error(createData.error);
|
||||
@@ -791,7 +851,6 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Terminal Rendering
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
@@ -836,9 +895,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
// Opt-in flicker filter: buffer screen clear patterns (for sessions that enable it)
|
||||
if (flickerFilterEnabled) {
|
||||
const hasScreenClear = data.includes('\x1b[2J') ||
|
||||
data.includes('\x1b[H\x1b[J') ||
|
||||
(data.includes('\x1b[H') && data.includes('\x1b[?25l'));
|
||||
const hasScreenClear =
|
||||
data.includes('\x1b[2J') ||
|
||||
data.includes('\x1b[H\x1b[J') ||
|
||||
(data.includes('\x1b[H') && data.includes('\x1b[?25l'));
|
||||
|
||||
if (hasScreenClear) {
|
||||
this.flickerFilterActive = true;
|
||||
@@ -904,47 +964,49 @@ Object.assign(CodemanApp.prototype, {
|
||||
* Position is tracked dynamically by _findPrompt() on every render.
|
||||
*/
|
||||
_updateLocalEchoState() {
|
||||
const settings = this.loadAppSettingsFromStorage();
|
||||
const session = this.activeSessionId ? this.sessions.get(this.activeSessionId) : null;
|
||||
const echoEnabled = settings.localEchoEnabled ?? MobileDetection.isTouchDevice();
|
||||
const shouldEnable = !!(echoEnabled && session);
|
||||
if (this._localEchoEnabled && !shouldEnable) {
|
||||
this._localEchoOverlay?.clear();
|
||||
}
|
||||
this._localEchoEnabled = shouldEnable;
|
||||
const settings = this.loadAppSettingsFromStorage();
|
||||
const session = this.activeSessionId ? this.sessions.get(this.activeSessionId) : null;
|
||||
const echoEnabled = settings.localEchoEnabled ?? MobileDetection.isTouchDevice();
|
||||
const shouldEnable = !!(echoEnabled && session);
|
||||
if (this._localEchoEnabled && !shouldEnable) {
|
||||
this._localEchoOverlay?.clear();
|
||||
}
|
||||
this._localEchoEnabled = shouldEnable;
|
||||
|
||||
// Swap prompt finder based on session mode
|
||||
if (this._localEchoOverlay && session) {
|
||||
if (session.mode === 'opencode') {
|
||||
// OpenCode (Bubble Tea TUI): find the ┃ border on the cursor's row.
|
||||
// The input area is "┃ <text>" — the ┃ is the anchor, offset 3 skips "┃ ".
|
||||
// We use the cursor row (cursorY) to find the right line, then scan for ┃.
|
||||
this._localEchoOverlay.setPrompt({
|
||||
type: 'custom',
|
||||
offset: 3,
|
||||
find: (terminal) => {
|
||||
try {
|
||||
const buf = terminal.buffer.active;
|
||||
const row = buf.cursorY;
|
||||
const line = buf.getLine(buf.viewportY + row);
|
||||
if (!line) return null;
|
||||
const text = line.translateToString(true);
|
||||
const idx = text.indexOf('\u2503'); // ┃ (BOX DRAWINGS HEAVY VERTICAL)
|
||||
if (idx >= 0) return { row, col: idx };
|
||||
return null;
|
||||
} catch { return null; }
|
||||
// Swap prompt finder based on session mode
|
||||
if (this._localEchoOverlay && session) {
|
||||
if (session.mode === 'opencode') {
|
||||
// OpenCode (Bubble Tea TUI): find the ┃ border on the cursor's row.
|
||||
// The input area is "┃ <text>" — the ┃ is the anchor, offset 3 skips "┃ ".
|
||||
// We use the cursor row (cursorY) to find the right line, then scan for ┃.
|
||||
this._localEchoOverlay.setPrompt({
|
||||
type: 'custom',
|
||||
offset: 3,
|
||||
find: (terminal) => {
|
||||
try {
|
||||
const buf = terminal.buffer.active;
|
||||
const row = buf.cursorY;
|
||||
const line = buf.getLine(buf.viewportY + row);
|
||||
if (!line) return null;
|
||||
const text = line.translateToString(true);
|
||||
const idx = text.indexOf('\u2503'); // ┃ (BOX DRAWINGS HEAVY VERTICAL)
|
||||
if (idx >= 0) return { row, col: idx };
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
} else if (session.mode === 'shell') {
|
||||
// Shell mode: the shell provides its own PTY echo so the overlay isn't needed.
|
||||
// Disable it by clearing any pending text.
|
||||
this._localEchoOverlay.clear();
|
||||
this._localEchoEnabled = false;
|
||||
} else {
|
||||
// Claude Code: scan for ❯ prompt character
|
||||
this._localEchoOverlay.setPrompt({ type: 'character', char: '\u276f', offset: 2 });
|
||||
}
|
||||
},
|
||||
});
|
||||
} else if (session.mode === 'shell') {
|
||||
// Shell mode: the shell provides its own PTY echo so the overlay isn't needed.
|
||||
// Disable it by clearing any pending text.
|
||||
this._localEchoOverlay.clear();
|
||||
this._localEchoEnabled = false;
|
||||
} else {
|
||||
// Claude Code: scan for ❯ prompt character
|
||||
this._localEchoOverlay.setPrompt({ type: 'character', char: '\u276f', offset: 2 });
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -961,7 +1023,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
const joined = this.pendingWrites.join('');
|
||||
this.pendingWrites = [];
|
||||
const _joinedLen = joined.length;
|
||||
if (_joinedLen > 16384) _crashDiag.log(`FLUSH: ${(_joinedLen/1024).toFixed(0)}KB`);
|
||||
if (_joinedLen > 16384) _crashDiag.log(`FLUSH: ${(_joinedLen / 1024).toFixed(0)}KB`);
|
||||
|
||||
// Per-frame byte budget to prevent main thread blocking.
|
||||
// Large writes (141KB+) can freeze Chrome for 2+ minutes.
|
||||
@@ -985,7 +1047,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
const bytesThisFrame = deferred ? MAX_FRAME_BYTES : _joinedLen;
|
||||
const _dt = performance.now() - _t0;
|
||||
if (_dt > 100 || deferred) console.warn(`[CRASH-DIAG] flushPendingWrites: ${_dt.toFixed(0)}ms, ${(bytesThisFrame/1024).toFixed(0)}KB written${deferred ? ', rest deferred' : ''} (total ${(_joinedLen/1024).toFixed(0)}KB)`);
|
||||
if (_dt > 100 || deferred)
|
||||
console.warn(
|
||||
`[CRASH-DIAG] flushPendingWrites: ${_dt.toFixed(0)}ms, ${(bytesThisFrame / 1024).toFixed(0)}KB written${deferred ? ', rest deferred' : ''} (total ${(_joinedLen / 1024).toFixed(0)}KB)`
|
||||
);
|
||||
|
||||
// Sticky scroll: if user was at bottom, keep them there after new output
|
||||
if (this._wasAtBottomBeforeWrite) {
|
||||
@@ -1002,8 +1067,12 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Use terminal.write('', callback) to defer detection until xterm.js
|
||||
// finishes processing ALL queued writes — direct buffer reads after
|
||||
// terminal.write(data) can miss text if xterm processes asynchronously.
|
||||
if (this._tabCompletionSessionId && this._tabCompletionSessionId === this.activeSessionId
|
||||
&& this._localEchoOverlay && !this._localEchoOverlay.pendingText) {
|
||||
if (
|
||||
this._tabCompletionSessionId &&
|
||||
this._tabCompletionSessionId === this.activeSessionId &&
|
||||
this._localEchoOverlay &&
|
||||
!this._localEchoOverlay.pendingText
|
||||
) {
|
||||
const overlay = this._localEchoOverlay;
|
||||
const self = this;
|
||||
this.terminal.write('', () => {
|
||||
@@ -1024,7 +1093,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
self._tabCompletionSessionId = null;
|
||||
self._tabCompletionRetries = 0;
|
||||
self._tabCompletionBaseText = null;
|
||||
if (self._tabCompletionFallback) { clearTimeout(self._tabCompletionFallback); self._tabCompletionFallback = null; }
|
||||
if (self._tabCompletionFallback) {
|
||||
clearTimeout(self._tabCompletionFallback);
|
||||
self._tabCompletionFallback = null;
|
||||
}
|
||||
overlay.rerender();
|
||||
}
|
||||
} else {
|
||||
@@ -1096,7 +1168,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
if (offset >= cleanBuffer.length) {
|
||||
const _totalMs = performance.now() - _chunkStart;
|
||||
console.log(`[CRASH-DIAG] chunkedTerminalWrite complete: ${cleanBuffer.length} bytes in ${_chunkCount} chunks, ${_totalMs.toFixed(0)}ms total`);
|
||||
console.log(
|
||||
`[CRASH-DIAG] chunkedTerminalWrite complete: ${cleanBuffer.length} bytes in ${_chunkCount} chunks, ${_totalMs.toFixed(0)}ms total`
|
||||
);
|
||||
// Wait one more frame for xterm to finish rendering before resolving
|
||||
requestAnimationFrame(finish);
|
||||
return;
|
||||
@@ -1107,7 +1181,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.terminal.write(chunk);
|
||||
const _cdt = performance.now() - _ct0;
|
||||
_chunkCount++;
|
||||
if (_cdt > 50) console.warn(`[CRASH-DIAG] chunk #${_chunkCount} write took ${_cdt.toFixed(0)}ms (${chunk.length} bytes at offset ${offset})`);
|
||||
if (_cdt > 50)
|
||||
console.warn(
|
||||
`[CRASH-DIAG] chunk #${_chunkCount} write took ${_cdt.toFixed(0)}ms (${chunk.length} bytes at offset ${offset})`
|
||||
);
|
||||
offset += chunkSize;
|
||||
|
||||
// Schedule next chunk on next frame
|
||||
@@ -1134,7 +1211,6 @@ Object.assign(CodemanApp.prototype, {
|
||||
this._loadBufferQueue = null;
|
||||
},
|
||||
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Terminal Controls
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
@@ -1168,7 +1244,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
await fetch(`/api/sessions/${this.activeSessionId}/input`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ input: '\x0c' })
|
||||
body: JSON.stringify({ input: '\x0c' }),
|
||||
});
|
||||
|
||||
this.showToast(`Terminal restored to ${dims.cols}x${dims.rows}`, 'success');
|
||||
@@ -1195,7 +1271,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
fetch(`/api/sessions/${sessionId}/input`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ input: '\x0c' })
|
||||
body: JSON.stringify({ input: '\x0c' }),
|
||||
});
|
||||
});
|
||||
},
|
||||
@@ -1257,7 +1333,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (!dims) return null;
|
||||
return {
|
||||
cols: Math.max(dims.cols, MIN_COLS),
|
||||
rows: Math.max(dims.rows, MIN_ROWS)
|
||||
rows: Math.max(dims.rows, MIN_ROWS),
|
||||
};
|
||||
},
|
||||
|
||||
@@ -1272,10 +1348,6 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (this.fitAddon) this.fitAddon.fit();
|
||||
const dims = this.getTerminalDimensions();
|
||||
if (!dims) return;
|
||||
// Update _lastResizeDims so the throttledResize handler won't redundantly
|
||||
// clear the terminal for the same dimensions (which would blank the screen
|
||||
// without a subsequent Ink redraw to repaint it).
|
||||
this._lastResizeDims = { cols: dims.cols, rows: dims.rows };
|
||||
// Fast path: WebSocket resize
|
||||
if (this._wsReady && this._wsSessionId === sessionId) {
|
||||
try {
|
||||
@@ -1288,7 +1360,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
await fetch(`/api/sessions/${sessionId}/resize`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(dims)
|
||||
body: JSON.stringify(dims),
|
||||
});
|
||||
},
|
||||
|
||||
@@ -1302,11 +1374,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
await fetch(`/api/sessions/${this.activeSessionId}/input`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ input, useMux: true })
|
||||
body: JSON.stringify({ input, useMux: true }),
|
||||
});
|
||||
},
|
||||
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Directory Input
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
Reference in New Issue
Block a user