mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 20:49:41 +02:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
82d08df53f | ||
|
|
2709b2fe49 | ||
|
|
b1d3b27e5b | ||
|
|
47963b54fa | ||
|
|
b7c3c30c8c | ||
|
|
0d80524f10 |
@@ -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",
|
||||
|
||||
@@ -22,14 +22,16 @@
|
||||
* Maximum terminal buffer size in characters.
|
||||
* Contains raw terminal output with ANSI escape sequences.
|
||||
* Reduced from 5MB to 2MB for better render performance.
|
||||
* Override: CODEMAN_MAX_TERMINAL_BUFFER (bytes)
|
||||
*/
|
||||
export const MAX_TERMINAL_BUFFER_SIZE = 2 * 1024 * 1024; // 2MB
|
||||
export const MAX_TERMINAL_BUFFER_SIZE = parseInt(process.env.CODEMAN_MAX_TERMINAL_BUFFER || '') || 2 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* Size to trim terminal buffer to when max is exceeded.
|
||||
* Keeps the most recent portion to preserve context.
|
||||
* Override: CODEMAN_TRIM_TERMINAL_TO (bytes)
|
||||
*/
|
||||
export const TRIM_TERMINAL_TO = 1.5 * 1024 * 1024; // 1.5MB
|
||||
export const TRIM_TERMINAL_TO = parseInt(process.env.CODEMAN_TRIM_TERMINAL_TO || '') || 1.5 * 1024 * 1024;
|
||||
|
||||
// ============================================================================
|
||||
// Text Output Buffer Limits
|
||||
@@ -38,13 +40,15 @@ export const TRIM_TERMINAL_TO = 1.5 * 1024 * 1024; // 1.5MB
|
||||
/**
|
||||
* Maximum text output buffer size in characters.
|
||||
* Contains ANSI-stripped text for search and analysis.
|
||||
* Override: CODEMAN_MAX_TEXT_OUTPUT (bytes)
|
||||
*/
|
||||
export const MAX_TEXT_OUTPUT_SIZE = 1 * 1024 * 1024; // 1MB
|
||||
export const MAX_TEXT_OUTPUT_SIZE = parseInt(process.env.CODEMAN_MAX_TEXT_OUTPUT || '') || 1 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* Size to trim text output buffer to when max is exceeded.
|
||||
* Override: CODEMAN_TRIM_TEXT_TO (bytes)
|
||||
*/
|
||||
export const TRIM_TEXT_TO = 768 * 1024; // 768KB
|
||||
export const TRIM_TEXT_TO = parseInt(process.env.CODEMAN_TRIM_TEXT_TO || '') || 768 * 1024;
|
||||
|
||||
// ============================================================================
|
||||
// Message Buffer Limits
|
||||
@@ -53,8 +57,9 @@ export const TRIM_TEXT_TO = 768 * 1024; // 768KB
|
||||
/**
|
||||
* Maximum number of Claude JSON messages to keep in memory per session.
|
||||
* Older messages are discarded when limit is exceeded.
|
||||
* Override: CODEMAN_MAX_MESSAGES (count)
|
||||
*/
|
||||
export const MAX_MESSAGES = 1000;
|
||||
export const MAX_MESSAGES = parseInt(process.env.CODEMAN_MAX_MESSAGES || '') || 1000;
|
||||
|
||||
// ============================================================================
|
||||
// Line Buffer Limits
|
||||
|
||||
+55
-16
@@ -886,6 +886,8 @@ class CodemanApp {
|
||||
// Server sends this after SSE backpressure clears — terminal data was dropped,
|
||||
// so reload the buffer to recover from any display corruption.
|
||||
if (!this.activeSessionId || !this.terminal) return;
|
||||
// Skip if buffer load already in progress — avoids competing clear+rewrite cycles
|
||||
if (this._isLoadingBuffer) return;
|
||||
try {
|
||||
const res = await fetch(`/api/sessions/${this.activeSessionId}/terminal?tail=${TERMINAL_TAIL_SIZE}`);
|
||||
const data = await res.json();
|
||||
@@ -909,6 +911,12 @@ class CodemanApp {
|
||||
|
||||
async _onSessionClearTerminal(data) {
|
||||
if (data.id === this.activeSessionId) {
|
||||
// Skip if selectSession is already loading the buffer — clearTerminal arriving
|
||||
// during buffer load would clear the terminal mid-write, causing visible flicker
|
||||
// and a race between two concurrent chunkedTerminalWrite calls (especially on mobile
|
||||
// where rAF is slower). selectSession will handle the final buffer state.
|
||||
if (this._isLoadingBuffer) return;
|
||||
|
||||
// Fetch buffer, clear terminal, write buffer, resize (no Ctrl+L needed)
|
||||
try {
|
||||
const res = await fetch(`/api/sessions/${data.id}/terminal`);
|
||||
@@ -1299,6 +1307,16 @@ class CodemanApp {
|
||||
});
|
||||
}
|
||||
|
||||
/** Show/hide the CJK input textarea based on user setting or server override */
|
||||
_updateCjkInputState() {
|
||||
const cjkEl = document.getElementById('cjkInput');
|
||||
if (!cjkEl) return;
|
||||
const settings = this.loadAppSettingsFromStorage();
|
||||
const showCjk = this._serverCjkOverride || settings.cjkInputEnabled || false;
|
||||
cjkEl.style.display = showCjk ? 'block' : 'none';
|
||||
if (!showCjk) window.cjkActive = false;
|
||||
}
|
||||
|
||||
handleInit(data) {
|
||||
// Clear the init fallback timer since we got data
|
||||
if (this._initFallbackTimer) {
|
||||
@@ -1307,12 +1325,9 @@ class CodemanApp {
|
||||
}
|
||||
const gen = ++this._initGeneration;
|
||||
|
||||
// CJK input form: show/hide based on server env INPUT_CJK_FORM=ON
|
||||
const cjkEl = document.getElementById('cjkInput');
|
||||
if (cjkEl) {
|
||||
cjkEl.style.display = data.inputCjkForm ? 'block' : 'none';
|
||||
if (!data.inputCjkForm) window.cjkActive = false;
|
||||
}
|
||||
// CJK input form: controlled by user setting (with server env as override)
|
||||
this._serverCjkOverride = data.inputCjkForm || false;
|
||||
this._updateCjkInputState();
|
||||
|
||||
// Update version displays (header and toolbar)
|
||||
if (data.version) {
|
||||
@@ -1988,6 +2003,12 @@ class CodemanApp {
|
||||
|
||||
async selectSession(sessionId) {
|
||||
if (this.activeSessionId === sessionId) return;
|
||||
// Focus terminal SYNCHRONOUSLY before any await — iOS Safari only honors
|
||||
// programmatic focus() within the user-gesture call stack (e.g. tab click).
|
||||
// After the first await the gesture context is lost and focus() is silently
|
||||
// ignored, leaving the keyboard unable to send input to the terminal.
|
||||
if (this.terminal) this.terminal.focus();
|
||||
|
||||
const _selStart = performance.now();
|
||||
const _selName = this.sessions.get(sessionId)?.name || sessionId.slice(0,8);
|
||||
_crashDiag.log(`SELECT: ${_selName}`);
|
||||
@@ -2153,11 +2174,14 @@ class CodemanApp {
|
||||
// canvas may be at stale dimensions — content would render at wrong width.
|
||||
if (this.fitAddon) this.fitAddon.fit();
|
||||
|
||||
// Instant cache restore — show previous buffer via chunked write to avoid WebGL GPU stalls.
|
||||
// Direct terminal.write() of large cached buffers (256KB+) can block the main thread
|
||||
// for 5+ seconds while the WebGL renderer processes ReadPixels synchronously.
|
||||
// Instant cache restore for IDLE sessions only.
|
||||
// For busy sessions, the cache is always stale — writing it first causes a
|
||||
// jarring double-render: stale content appears, then the terminal flashes
|
||||
// blank and rewrites with fresh data. Skip the cache and write the fresh
|
||||
// buffer once for a single clean transition.
|
||||
const cachedBuffer = this.terminalBufferCache.get(sessionId);
|
||||
if (cachedBuffer) {
|
||||
const sessionIsBusy = session && (session.status === 'busy' || session.status === 'working');
|
||||
if (cachedBuffer && !sessionIsBusy) {
|
||||
_crashDiag.log(`CACHE_WRITE: ${(cachedBuffer.length/1024).toFixed(0)}KB`);
|
||||
this.terminal.clear();
|
||||
this.terminal.reset();
|
||||
@@ -2165,6 +2189,11 @@ class CodemanApp {
|
||||
if (selectGen !== this._selectGeneration) { if (this._isLoadingBuffer) this._finishBufferLoad(); this._restoringFlushedState = false; return; }
|
||||
this.terminal.scrollToBottom();
|
||||
_crashDiag.log('CACHE_DONE');
|
||||
} else if (sessionIsBusy) {
|
||||
// Clear stale content immediately — fresh buffer is being fetched
|
||||
this.terminal.clear();
|
||||
this.terminal.reset();
|
||||
_crashDiag.log('CACHE_SKIP_BUSY');
|
||||
}
|
||||
|
||||
_crashDiag.log('FETCH_START');
|
||||
@@ -2206,9 +2235,9 @@ class CodemanApp {
|
||||
this.terminal.reset();
|
||||
}
|
||||
|
||||
// Buffer load complete — unblock live SSE writes and flush any queued events.
|
||||
// chunkedTerminalWrite calls _finishBufferLoad internally, but if we skipped
|
||||
// the chunked write (small buffer, cache hit, or empty), we must call it here.
|
||||
// Buffer load complete — unblock live SSE writes (queued events are discarded
|
||||
// to prevent duplicate content). chunkedTerminalWrite calls _finishBufferLoad
|
||||
// internally, but if we skipped the write (cache hit or empty), call it here.
|
||||
if (this._isLoadingBuffer) {
|
||||
this._finishBufferLoad();
|
||||
}
|
||||
@@ -2232,9 +2261,19 @@ class CodemanApp {
|
||||
});
|
||||
}
|
||||
|
||||
// Fire-and-forget resize — don't await to avoid blocking UI.
|
||||
// The resize triggers an Ink redraw in Claude which streams back via SSE.
|
||||
this.sendResize(sessionId);
|
||||
// Fire-and-forget resize + Ctrl+L to force Ink redraw.
|
||||
// Tailed buffers accumulate stale CUP-positioned Ink frames that overlap
|
||||
// in the viewport (e.g. duplicate "bypass permissions" bars). Ctrl+L
|
||||
// triggers a full Ink redraw which overwrites all stale frame content.
|
||||
// sendResize may be a no-op if dimensions match, so Ctrl+L is essential.
|
||||
this.sendResize(sessionId).then(() => {
|
||||
if (selectGen !== this._selectGeneration) return;
|
||||
fetch(`/api/sessions/${sessionId}/input`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ input: '\x0c' })
|
||||
}).catch(() => {});
|
||||
});
|
||||
|
||||
// Defer secondary panel updates so they don't block the main thread
|
||||
// after terminal content is already visible.
|
||||
|
||||
@@ -871,6 +871,16 @@
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="settings-item settings-item-multiline" title="Show a dedicated input field below the terminal for CJK (Chinese/Japanese/Korean) IME composition. Recommended for mobile devices with Chinese input methods where xterm's native input handling may drop characters.">
|
||||
<div class="settings-item-text">
|
||||
<span class="settings-item-label">CJK Input</span>
|
||||
<span class="settings-item-desc">Dedicated IME input field for CJK languages</span>
|
||||
</div>
|
||||
<label class="switch switch-sm">
|
||||
<input type="checkbox" id="appSettingsCjkInput">
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Header Displays Section -->
|
||||
<div class="settings-section-header">Header Displays</div>
|
||||
|
||||
+169
-31
@@ -3,10 +3,36 @@
|
||||
*
|
||||
* Always-visible textarea below the terminal (in index.html).
|
||||
* The browser handles IME composition natively — we just read
|
||||
* textarea.value on Enter and send it to PTY.
|
||||
* textarea.value and send it to PTY.
|
||||
* While this textarea has focus, window.cjkActive = true blocks xterm's onData.
|
||||
* Arrow keys and function keys are forwarded to PTY directly.
|
||||
*
|
||||
* ## Android IME challenge
|
||||
*
|
||||
* Android virtual keyboards (WeChat, Sogou, Gboard in Chinese mode) use
|
||||
* composition for EVERYTHING — including English prediction and punctuation.
|
||||
* This means compositionstart fires even for English text, and compositionend
|
||||
* may not fire until the user explicitly confirms (space, candidate tap).
|
||||
*
|
||||
* We use InputEvent.inputType to distinguish:
|
||||
* - `insertCompositionText`: tentative text, may change (CJK candidates, pinyin)
|
||||
* - `insertText`: final committed text (confirmed word, punctuation, space)
|
||||
*
|
||||
* During composition, `insertText` events are flushed immediately (punctuation,
|
||||
* English words confirmed by IME). `insertCompositionText` waits for
|
||||
* compositionend (CJK candidate selection).
|
||||
*
|
||||
* ## Phantom character for Android backspace
|
||||
*
|
||||
* Android virtual keyboards don't generate key-repeat keydown events for held
|
||||
* keys. When the textarea is empty, backspace produces no `input` event either
|
||||
* (nothing to delete). We keep a zero-width space (U+200B) "phantom" in the
|
||||
* textarea at all times. Backspace deletes the phantom → `input` fires with
|
||||
* `deleteContentBackward` → we send \x7f to PTY and restore the phantom.
|
||||
* Long-press backspace generates rapid deleteContentBackward events, each
|
||||
* handled the same way — giving continuous deletion at the keyboard's native
|
||||
* repeat rate.
|
||||
*
|
||||
* @dependency index.html (#cjkInput textarea)
|
||||
* @globals {object} CjkInput — window.cjkActive (boolean) signals app.js to block xterm onData
|
||||
* @loadorder 5.5 of 15 — loaded after keyboard-accessory.js, before app.js
|
||||
@@ -17,10 +43,12 @@ const CjkInput = (() => {
|
||||
let _textarea = null;
|
||||
let _send = null;
|
||||
let _initialized = false;
|
||||
let _onMousedown = null;
|
||||
let _onFocus = null;
|
||||
let _onBlur = null;
|
||||
let _onKeydown = null;
|
||||
let _composing = false;
|
||||
const _listeners = {};
|
||||
|
||||
// Zero-width space: always present in textarea so Android backspace has
|
||||
// something to delete, triggering the `input` event we need to detect it.
|
||||
const PHANTOM = '\u200B';
|
||||
|
||||
const PASSTHROUGH_KEYS = {
|
||||
ArrowUp: '\x1b[A',
|
||||
@@ -36,66 +64,176 @@ const CjkInput = (() => {
|
||||
c: '\x03', d: '\x04', l: '\x0c', z: '\x1a', a: '\x01', e: '\x05',
|
||||
};
|
||||
|
||||
/** Strip phantom characters from a string */
|
||||
function _strip(str) {
|
||||
return str.replace(/\u200B/g, '');
|
||||
}
|
||||
|
||||
/** Reset textarea to phantom-only state with cursor at end */
|
||||
function _resetToPhantom() {
|
||||
_textarea.value = PHANTOM;
|
||||
_textarea.setSelectionRange(1, 1);
|
||||
}
|
||||
|
||||
/** Check if textarea contains only phantom(s) or is empty — no real user text */
|
||||
function _isEffectivelyEmpty() {
|
||||
return !_strip(_textarea.value);
|
||||
}
|
||||
|
||||
/** Flush textarea: send real text to PTY and reset to phantom */
|
||||
function _flush() {
|
||||
const val = _strip(_textarea.value);
|
||||
if (val) {
|
||||
_send(val);
|
||||
}
|
||||
_resetToPhantom();
|
||||
}
|
||||
|
||||
return {
|
||||
init({ send }) {
|
||||
// Guard against double-init: remove previous listeners
|
||||
if (_initialized) this.destroy();
|
||||
|
||||
_send = send;
|
||||
_composing = false;
|
||||
_textarea = document.getElementById('cjkInput');
|
||||
if (!_textarea) return this;
|
||||
|
||||
_onMousedown = (e) => { e.stopPropagation(); };
|
||||
_onFocus = () => { window.cjkActive = true; };
|
||||
_onBlur = () => { window.cjkActive = false; };
|
||||
_textarea.addEventListener('mousedown', _onMousedown);
|
||||
_textarea.addEventListener('focus', _onFocus);
|
||||
_textarea.addEventListener('blur', _onBlur);
|
||||
// Seed the phantom character
|
||||
_resetToPhantom();
|
||||
|
||||
_onKeydown = (e) => {
|
||||
if (e.isComposing || e.keyCode === 229) return;
|
||||
_listeners.mousedown = (e) => { e.stopPropagation(); };
|
||||
_listeners.focus = () => {
|
||||
window.cjkActive = true;
|
||||
// Restore phantom if textarea was emptied while blurred
|
||||
if (!_textarea.value) _resetToPhantom();
|
||||
};
|
||||
_listeners.blur = () => { window.cjkActive = false; };
|
||||
_textarea.addEventListener('mousedown', _listeners.mousedown);
|
||||
_textarea.addEventListener('focus', _listeners.focus);
|
||||
_textarea.addEventListener('blur', _listeners.blur);
|
||||
|
||||
// Enter: send accumulated text (or bare Enter if empty)
|
||||
// ── Composition tracking ──
|
||||
_listeners.compositionstart = () => {
|
||||
_composing = true;
|
||||
// Clear phantom so IME sees a clean textarea — some IMEs include
|
||||
// existing text in the composition region which would corrupt input.
|
||||
if (_textarea.value === PHANTOM) {
|
||||
_textarea.value = '';
|
||||
}
|
||||
};
|
||||
_listeners.compositionend = () => {
|
||||
_composing = false;
|
||||
// Defer flush: some Android IMEs haven't committed text to textarea
|
||||
// when compositionend fires. setTimeout(0) ensures we read the final value.
|
||||
setTimeout(_flush, 0);
|
||||
};
|
||||
_textarea.addEventListener('compositionstart', _listeners.compositionstart);
|
||||
_textarea.addEventListener('compositionend', _listeners.compositionend);
|
||||
|
||||
// ── Keydown: special keys work REGARDLESS of composition state ──
|
||||
_listeners.keydown = (e) => {
|
||||
// Enter: flush accumulated text (or bare Enter if empty).
|
||||
// No isComposing guard — Android IMEs set isComposing=true for English
|
||||
// prediction, but Enter should ALWAYS send. We preventDefault to stop
|
||||
// the IME from also handling Enter (which could double-send or do nothing).
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (_textarea.value) {
|
||||
_send(_textarea.value + '\r');
|
||||
_textarea.value = '';
|
||||
_composing = false;
|
||||
const val = _strip(_textarea.value);
|
||||
if (val) {
|
||||
_send(val + '\r');
|
||||
} else {
|
||||
_send('\r');
|
||||
}
|
||||
_resetToPhantom();
|
||||
return;
|
||||
}
|
||||
|
||||
// Escape: clear textarea
|
||||
// Escape: clear textarea (always works)
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
_textarea.value = '';
|
||||
_composing = false;
|
||||
_resetToPhantom();
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl combos: forward to PTY
|
||||
// Ctrl combos: forward to PTY (always works)
|
||||
if (e.ctrlKey && CTRL_KEYS[e.key]) {
|
||||
e.preventDefault();
|
||||
_send(CTRL_KEYS[e.key]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Backspace: delete from textarea if has text, else forward to PTY
|
||||
if (e.key === 'Backspace' && !_textarea.value) {
|
||||
// Below: only when NOT composing (composing keystrokes belong to IME)
|
||||
if (_composing) return;
|
||||
|
||||
// Backspace: forward to PTY when no real text in textarea
|
||||
// (Desktop path — Android uses the input event + phantom approach)
|
||||
if (e.key === 'Backspace' && _isEffectivelyEmpty()) {
|
||||
e.preventDefault();
|
||||
_send('\x7f');
|
||||
_resetToPhantom();
|
||||
return;
|
||||
}
|
||||
|
||||
// Arrow/function keys: forward to PTY when textarea is empty
|
||||
if (PASSTHROUGH_KEYS[e.key] && !_textarea.value) {
|
||||
// Arrow/function keys: forward to PTY when no real text
|
||||
if (PASSTHROUGH_KEYS[e.key] && _isEffectivelyEmpty()) {
|
||||
e.preventDefault();
|
||||
_send(PASSTHROUGH_KEYS[e.key]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Single printable character: send immediately to PTY
|
||||
// (Desktop keyboards with physical keys — Android sends 'Unidentified')
|
||||
if (e.key.length === 1 && !e.ctrlKey && !e.altKey && !e.metaKey && _isEffectivelyEmpty()) {
|
||||
e.preventDefault();
|
||||
_send(e.key);
|
||||
return;
|
||||
}
|
||||
};
|
||||
_textarea.addEventListener('keydown', _onKeydown);
|
||||
_textarea.addEventListener('keydown', _listeners.keydown);
|
||||
|
||||
// ── Input event: the primary path for Android virtual keyboards ──
|
||||
// Android sends keyCode 229 + key "Unidentified" for virtual key presses,
|
||||
// making keydown unreliable. input fires AFTER character insertion and
|
||||
// carries inputType which tells us whether the text is final or tentative.
|
||||
_listeners.input = (e) => {
|
||||
// ── Backspace / delete detection ──
|
||||
// Android long-press backspace generates rapid deleteContentBackward events.
|
||||
// The phantom character ensures the textarea is never truly empty, so each
|
||||
// press/repeat fires an input event that we can catch here.
|
||||
if (e.inputType === 'deleteContentBackward' || e.inputType === 'deleteWordBackward') {
|
||||
if (_isEffectivelyEmpty()) {
|
||||
// No real text left — forward backspace to PTY
|
||||
_send('\x7f');
|
||||
_resetToPhantom();
|
||||
return;
|
||||
}
|
||||
// User is editing their own text in the textarea — let it be.
|
||||
// Ensure phantom is still present for the NEXT backspace.
|
||||
if (!_textarea.value.startsWith(PHANTOM)) {
|
||||
_textarea.value = PHANTOM + _textarea.value;
|
||||
_textarea.setSelectionRange(1, 1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (_composing) {
|
||||
// insertText during composition = IME committed final text
|
||||
// (e.g., punctuation key inserts 。directly, or IME confirms a word).
|
||||
// Flush immediately — this text won't change.
|
||||
if (e.inputType === 'insertText') {
|
||||
_flush();
|
||||
return;
|
||||
}
|
||||
// insertCompositionText = IME is still working (pinyin, candidates,
|
||||
// English prediction). Wait for compositionend to flush.
|
||||
return;
|
||||
}
|
||||
// Outside composition: send immediately
|
||||
_flush();
|
||||
};
|
||||
_textarea.addEventListener('input', _listeners.input);
|
||||
|
||||
_initialized = true;
|
||||
return this;
|
||||
@@ -103,13 +241,13 @@ const CjkInput = (() => {
|
||||
|
||||
destroy() {
|
||||
if (_textarea) {
|
||||
if (_onMousedown) _textarea.removeEventListener('mousedown', _onMousedown);
|
||||
if (_onFocus) _textarea.removeEventListener('focus', _onFocus);
|
||||
if (_onBlur) _textarea.removeEventListener('blur', _onBlur);
|
||||
if (_onKeydown) _textarea.removeEventListener('keydown', _onKeydown);
|
||||
for (const [event, handler] of Object.entries(_listeners)) {
|
||||
if (handler) _textarea.removeEventListener(event, handler);
|
||||
}
|
||||
}
|
||||
window.cjkActive = false;
|
||||
_onMousedown = _onFocus = _onBlur = _onKeydown = null;
|
||||
_composing = false;
|
||||
for (const key of Object.keys(_listeners)) delete _listeners[key];
|
||||
_initialized = false;
|
||||
},
|
||||
|
||||
|
||||
@@ -1147,6 +1147,7 @@ html.mobile-init .file-browser-panel {
|
||||
|
||||
/* Compact welcome overlay for mobile */
|
||||
.welcome-content {
|
||||
max-width: calc(100vw - 1.5rem);
|
||||
padding: 1rem 0.75rem;
|
||||
}
|
||||
|
||||
|
||||
@@ -273,6 +273,11 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.terminal.clear();
|
||||
this.terminal.writeln(`\x1b[1;32m Starting ${tabCount} Claude session(s) in ${caseName}...\x1b[0m`);
|
||||
this.terminal.writeln('');
|
||||
// Focus terminal NOW, in the synchronous user-gesture context (button click).
|
||||
// iOS Safari ignores programmatic focus() after any await, so this must happen
|
||||
// before the first async call. The keyboard opens here and stays open through
|
||||
// the session creation flow; selectSession at the end inherits the focus state.
|
||||
this.terminal.focus();
|
||||
|
||||
try {
|
||||
// Get case path first
|
||||
@@ -493,6 +498,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.terminal.clear();
|
||||
this.terminal.writeln(`\x1b[1;32m Starting OpenCode session in ${caseName}...\x1b[0m`);
|
||||
this.terminal.writeln('');
|
||||
// Focus in sync gesture context (see runClaude comment)
|
||||
this.terminal.focus();
|
||||
|
||||
try {
|
||||
// Check if OpenCode is available
|
||||
|
||||
@@ -364,6 +364,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
document.getElementById('appSettingsTunnelEnabled').checked = settings.tunnelEnabled ?? false;
|
||||
this.loadTunnelStatus();
|
||||
document.getElementById('appSettingsLocalEcho').checked = settings.localEchoEnabled ?? MobileDetection.isTouchDevice();
|
||||
document.getElementById('appSettingsCjkInput').checked = settings.cjkInputEnabled ?? false;
|
||||
document.getElementById('appSettingsTabTwoRows').checked = settings.tabTwoRows ?? defaults.tabTwoRows ?? false;
|
||||
// Claude CLI settings
|
||||
const claudeModeSelect = document.getElementById('appSettingsClaudeMode');
|
||||
@@ -1180,6 +1181,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
imageWatcherEnabled: document.getElementById('appSettingsImageWatcherEnabled').checked,
|
||||
tunnelEnabled: document.getElementById('appSettingsTunnelEnabled').checked,
|
||||
localEchoEnabled: document.getElementById('appSettingsLocalEcho').checked,
|
||||
cjkInputEnabled: document.getElementById('appSettingsCjkInput').checked,
|
||||
tabTwoRows: document.getElementById('appSettingsTabTwoRows').checked,
|
||||
// Claude CLI settings
|
||||
claudeMode: document.getElementById('appSettingsClaudeMode').value,
|
||||
@@ -1296,9 +1298,12 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.renderProjectInsightsPanel(); // Re-render to apply visibility setting
|
||||
this.updateSubagentWindowVisibility(); // Apply subagent window visibility setting
|
||||
|
||||
// Apply CJK input visibility immediately
|
||||
this._updateCjkInputState();
|
||||
|
||||
// Save to server (includes notification prefs for cross-browser persistence)
|
||||
// Strip device-specific keys — localEchoEnabled is per-platform (touch default differs)
|
||||
const { localEchoEnabled: _leo, ...serverSettings } = settings;
|
||||
// Strip device-specific keys — localEchoEnabled/cjkInputEnabled are per-platform
|
||||
const { localEchoEnabled: _leo, cjkInputEnabled: _cjk, ...serverSettings } = settings;
|
||||
try {
|
||||
await this._apiPut('/api/settings', { ...serverSettings, notificationPreferences: notifPrefsToSave, voiceSettings });
|
||||
|
||||
@@ -1682,7 +1687,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
const displayKeys = new Set([
|
||||
'showFontControls', 'showSystemStats', 'showTokenCount', 'showCost',
|
||||
'showMonitor', 'showProjectInsights', 'showFileBrowser', 'showSubagents',
|
||||
'subagentActiveTabOnly', 'tabTwoRows', 'localEchoEnabled',
|
||||
'subagentActiveTabOnly', 'tabTwoRows', 'localEchoEnabled', 'cjkInputEnabled',
|
||||
]);
|
||||
// Merge settings: non-display keys always sync from server,
|
||||
// display keys only seed from server when localStorage has no value
|
||||
|
||||
@@ -94,7 +94,11 @@ textarea:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* xterm.js hidden textarea — must never show focus ring or caret */
|
||||
/* xterm.js hidden textarea — must never show focus ring or caret.
|
||||
* On mobile (touch devices), override xterm.css defaults that position the
|
||||
* textarea at left:-9999em with width/height:0. iOS Safari ignores keyboard
|
||||
* input to off-screen zero-size textareas, so we move it on-screen with a
|
||||
* minimal size while keeping it visually invisible. */
|
||||
.xterm-helper-textarea {
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
@@ -102,6 +106,14 @@ textarea:focus-visible {
|
||||
opacity: 0 !important;
|
||||
caret-color: transparent !important;
|
||||
}
|
||||
.touch-device .xterm .xterm-helper-textarea {
|
||||
left: 0 !important;
|
||||
top: 0 !important;
|
||||
width: 1px !important;
|
||||
height: 1px !important;
|
||||
z-index: -1 !important;
|
||||
font-size: 16px !important; /* prevent iOS auto-zoom on focus */
|
||||
}
|
||||
|
||||
/* Session tab focus */
|
||||
.session-tab:focus-visible {
|
||||
|
||||
+221
-135
@@ -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,46 +179,65 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Accumulate sub-line pixel deltas so slow swipes still scroll
|
||||
let pixelAccum = 0;
|
||||
|
||||
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;
|
||||
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;
|
||||
}, { passive: true });
|
||||
},
|
||||
{ passive: true }
|
||||
);
|
||||
}
|
||||
|
||||
// Welcome message
|
||||
@@ -267,8 +291,13 @@ Object.assign(CodemanApp.prototype, {
|
||||
// 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()) {
|
||||
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
|
||||
@@ -281,14 +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) {
|
||||
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(() => {});
|
||||
}
|
||||
}
|
||||
@@ -442,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;
|
||||
@@ -457,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('', () => {
|
||||
@@ -556,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);
|
||||
@@ -573,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;
|
||||
@@ -588,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);
|
||||
@@ -625,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');
|
||||
@@ -694,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');
|
||||
@@ -762,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);
|
||||
@@ -780,7 +851,6 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Terminal Rendering
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
@@ -825,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;
|
||||
@@ -893,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 });
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -950,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.
|
||||
@@ -974,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) {
|
||||
@@ -991,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('', () => {
|
||||
@@ -1013,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 {
|
||||
@@ -1085,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;
|
||||
@@ -1096,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
|
||||
@@ -1109,21 +1197,20 @@ Object.assign(CodemanApp.prototype, {
|
||||
},
|
||||
|
||||
/**
|
||||
* Complete a buffer load: unblock live SSE writes and flush any queued events.
|
||||
* Complete a buffer load: unblock live SSE writes.
|
||||
* Called when chunkedTerminalWrite finishes (or is skipped for empty buffers).
|
||||
*
|
||||
* Queued SSE events are DISCARDED, not flushed. The loaded buffer from the API
|
||||
* is the source of truth up to the response timestamp. SSE events queued during
|
||||
* the fetch+write overlap with the buffer — flushing them writes duplicate data
|
||||
* (especially Ink cursor-up redraws), corrupting the terminal display.
|
||||
* After unblocking, new SSE/WS events deliver subsequent output normally.
|
||||
*/
|
||||
_finishBufferLoad() {
|
||||
const queue = this._loadBufferQueue;
|
||||
this._isLoadingBuffer = false;
|
||||
this._loadBufferQueue = null;
|
||||
if (queue && queue.length > 0) {
|
||||
for (const data of queue) {
|
||||
this.batchTerminalWrite(data);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Terminal Controls
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
@@ -1157,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');
|
||||
@@ -1184,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' }),
|
||||
});
|
||||
});
|
||||
},
|
||||
@@ -1246,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),
|
||||
};
|
||||
},
|
||||
|
||||
@@ -1273,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),
|
||||
});
|
||||
},
|
||||
|
||||
@@ -1287,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