mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-08 08:29:42 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
de864e7d63 |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"aicodeman": patch
|
||||
---
|
||||
|
||||
Keep the terminal anchored where you are reading while an agent streams (#358). Scrolling up during a Codex response could still be dragged back to the live bottom by the next redraw: the flush captured the viewport before writing and restored it immediately after, but xterm parses asynchronously, so at that moment the buffer had not moved yet, the restore compared the anchor against itself and did nothing, and the redraw landed a tick later with nothing left to pull the view back. The restore now runs inside xterm's own write callback, which is the first point at which the redraw's effect exists, and it holds across consecutive and chunked redraws. It is dropped if you switch sessions or a history replay starts before the write parses, since the anchor indexes the buffer it was captured from.
|
||||
@@ -15,7 +15,7 @@ page says so and names the setting.
|
||||
| **Header, left** | The "C" logo (goes home) and the session list, unless you moved it to the sidebar. |
|
||||
| **Header, right** | Status chips and panel buttons, most of them off by default. |
|
||||
| **Center** | The terminal for the active session, or the home screen when nothing is selected. |
|
||||
| **Bottom toolbar** | Run, Stop, Run Shell, the case picker, and the instance counter. |
|
||||
| **Bottom toolbar** | Run, Stop, Run Shell, the case picker, and the instance counters. |
|
||||
| **Overlays** | Panels and modals: Respawn, Cron, Subagents, File Viewer, Settings. |
|
||||
|
||||
## Session list layout
|
||||
|
||||
@@ -102,8 +102,6 @@
|
||||
'Manage AI Coding tools in persistent tmux sessions.': '在持久化 tmux 会话中管理 AI 编程工具。',
|
||||
'Select case': '选择案例',
|
||||
'Select Case': '选择案例',
|
||||
'Search cases': '搜索案例',
|
||||
'No cases match': '没有匹配的案例',
|
||||
'All cases': '全部案例',
|
||||
'No directory': '未选择目录',
|
||||
Run: '运行',
|
||||
|
||||
@@ -692,9 +692,11 @@
|
||||
<button class="btn-toolbar btn-enter" onclick="app.sendEnterKey()" title="Send Enter">
|
||||
Enter
|
||||
</button>
|
||||
<!-- Run Shell had a second, identical instance-count stepper here. The
|
||||
toolbar carried two of them side by side, so it is gone and Run
|
||||
Shell reads the one above (#tabCount) like the Run button does. -->
|
||||
<div class="tab-count-group" title="Instance count">
|
||||
<button class="tab-count-btn" onclick="app.decrementShellCount()">−</button>
|
||||
<input type="number" id="shellCount" class="tab-count-input" value="1" min="1" max="20" readonly>
|
||||
<button class="tab-count-btn" onclick="app.incrementShellCount()">+</button>
|
||||
</div>
|
||||
<div class="case-select-group">
|
||||
<div class="case-combobox" id="quickStartCasePicker">
|
||||
<input
|
||||
@@ -3015,33 +3017,6 @@
|
||||
<h3>Select Case</h3>
|
||||
<button class="modal-close" onclick="app.closeMobileCasePicker()" aria-label="Close case picker">×</button>
|
||||
</div>
|
||||
<!-- Search: the desktop toolbar has had a filtering combobox for a while
|
||||
(#quickStartCaseSearch); this is the same matcher on the phone sheet,
|
||||
where a long case list is otherwise a long scroll. -->
|
||||
<div class="mobile-case-picker-search">
|
||||
<span class="mobile-case-search-icon" aria-hidden="true">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="11" cy="11" r="7"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
|
||||
</svg>
|
||||
</span>
|
||||
<input
|
||||
type="search"
|
||||
id="mobileCaseSearch"
|
||||
class="mobile-case-search-input"
|
||||
placeholder="Search cases"
|
||||
aria-label="Search cases"
|
||||
aria-controls="mobileCaseList"
|
||||
autocomplete="off"
|
||||
autocapitalize="none"
|
||||
autocorrect="off"
|
||||
spellcheck="false"
|
||||
enterkeyhint="search"
|
||||
oninput="app.filterMobileCaseList()"
|
||||
onkeydown="app.handleMobileCaseSearchKeydown(event)"
|
||||
>
|
||||
<button type="button" class="mobile-case-search-clear" id="mobileCaseSearchClear"
|
||||
onclick="app.clearMobileCaseSearch()" aria-label="Clear search" hidden>×</button>
|
||||
</div>
|
||||
<div class="mobile-case-picker-body">
|
||||
<div class="mobile-case-list" id="mobileCaseList">
|
||||
<!-- Cases populated by JS -->
|
||||
|
||||
@@ -410,12 +410,6 @@ const KeyboardHandler = {
|
||||
const keyboardHeight = this.initialViewportHeight - (window.visualViewport.height || window.innerHeight);
|
||||
const accessoryBar = document.querySelector('.keyboard-accessory-bar');
|
||||
|
||||
// The mobile case picker is a third position:fixed bottom-anchored
|
||||
// surface, and since it gained a search field the keyboard can open over
|
||||
// it. iOS does not shrink the layout viewport, so an unlifted sheet sits
|
||||
// BEHIND the keyboard with its own search box out of sight.
|
||||
const caseSheet = document.querySelector('.mobile-case-picker.active .mobile-case-picker-sheet');
|
||||
|
||||
if (isSmallMedium) {
|
||||
// Phones/small tablets: toolbar and accessory bar are position:fixed
|
||||
// via CSS. Use translateY to lift them above the keyboard.
|
||||
@@ -432,9 +426,6 @@ const KeyboardHandler = {
|
||||
if (accessoryBar) {
|
||||
accessoryBar.style.transform = keyboardOffset > 0 ? `translateY(${-keyboardOffset}px)` : '';
|
||||
}
|
||||
if (caseSheet) {
|
||||
caseSheet.style.transform = keyboardOffset > 0 ? `translateY(${-keyboardOffset}px)` : '';
|
||||
}
|
||||
if (main && keyboardHeight > 0) {
|
||||
const cjkInputHeight = cjkInput?.classList.contains('cjk-input-visible') ? 44 : 0;
|
||||
main.style.paddingBottom = `${84 + cjkInputHeight}px`;
|
||||
@@ -445,9 +436,6 @@ const KeyboardHandler = {
|
||||
if (accessoryBar) {
|
||||
accessoryBar.style.bottom = `${keyboardHeight}px`;
|
||||
}
|
||||
if (caseSheet) {
|
||||
caseSheet.style.bottom = `${keyboardHeight}px`;
|
||||
}
|
||||
}
|
||||
|
||||
// CJK textarea positioning (always position:fixed on touch devices).
|
||||
@@ -476,10 +464,6 @@ const KeyboardHandler = {
|
||||
const accessoryBar = document.querySelector('.keyboard-accessory-bar');
|
||||
const cjkInput = document.getElementById('cjkInput');
|
||||
const main = document.querySelector('.main');
|
||||
// Not scoped to `.active`, unlike the lift above: a sheet closed while the
|
||||
// keyboard was still up must still have its inline offset cleared, or the
|
||||
// next open slides in already displaced.
|
||||
const caseSheet = document.querySelector('.mobile-case-picker-sheet');
|
||||
|
||||
if (toolbar) {
|
||||
toolbar.style.transform = '';
|
||||
@@ -492,10 +476,6 @@ const KeyboardHandler = {
|
||||
cjkInput.style.transform = '';
|
||||
cjkInput.style.bottom = '';
|
||||
}
|
||||
if (caseSheet) {
|
||||
caseSheet.style.transform = '';
|
||||
caseSheet.style.bottom = '';
|
||||
}
|
||||
if (main) {
|
||||
main.style.paddingBottom = '';
|
||||
}
|
||||
|
||||
@@ -2164,18 +2164,6 @@ html.mobile-init .file-browser-panel {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
/* With the keyboard up the sheet is lifted above it (mobile-handlers.js), so
|
||||
what is left to fit is much shorter than 60vh of the layout viewport. Cap
|
||||
the list rather than the sheet, so the search row and the Create button
|
||||
stay on screen and only the rows scroll. */
|
||||
.keyboard-visible .mobile-case-picker-sheet {
|
||||
max-height: 45vh;
|
||||
}
|
||||
|
||||
.keyboard-visible .mobile-case-picker-body {
|
||||
max-height: 28vh;
|
||||
}
|
||||
|
||||
.mobile-case-picker-footer {
|
||||
padding-bottom: calc(12px + var(--safe-area-bottom));
|
||||
}
|
||||
|
||||
@@ -869,17 +869,17 @@ Object.assign(CodemanApp.prototype, {
|
||||
input.value = Math.max(1, current - 1);
|
||||
},
|
||||
|
||||
/**
|
||||
* How many sessions the next launch creates, from the toolbar's single
|
||||
* instance stepper. Run Shell used to carry a second, identical `− 1 +` group
|
||||
* of its own (`#shellCount`); that one is gone, so both launch paths read
|
||||
* this control. An absent stepper reads as 1 rather than throwing: the group
|
||||
* is display:none on phones and tablets, and the vm-based unit tests stub
|
||||
* only the elements they exercise.
|
||||
*/
|
||||
_toolbarInstanceCount() {
|
||||
const raw = document.getElementById('tabCount')?.value;
|
||||
return Math.min(20, Math.max(1, parseInt(raw, 10) || 1));
|
||||
// Shell count stepper functions
|
||||
incrementShellCount() {
|
||||
const input = document.getElementById('shellCount');
|
||||
const current = parseInt(input.value) || 1;
|
||||
input.value = Math.min(20, current + 1);
|
||||
},
|
||||
|
||||
decrementShellCount() {
|
||||
const input = document.getElementById('shellCount');
|
||||
const current = parseInt(input.value) || 1;
|
||||
input.value = Math.max(1, current - 1);
|
||||
},
|
||||
|
||||
// Next free <prefix><n> index for a case's session tabs (e.g. w1-<case>,
|
||||
@@ -930,7 +930,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
async runClaude() {
|
||||
const caseName = document.getElementById('quickStartCase').value || 'testcase';
|
||||
const tabCount = this._toolbarInstanceCount();
|
||||
const tabCount = Math.min(20, Math.max(1, parseInt(document.getElementById('tabCount').value) || 1));
|
||||
|
||||
const ownsLaunchTerminal = this._beginSessionLaunchStatus(
|
||||
`Starting ${tabCount} Claude session(s) in ${caseName}...`
|
||||
@@ -1142,7 +1142,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
async runShell() {
|
||||
const caseName = document.getElementById('quickStartCase').value || 'testcase';
|
||||
const shellCount = this._toolbarInstanceCount();
|
||||
const shellCount = Math.min(20, Math.max(1, parseInt(document.getElementById('shellCount').value) || 1));
|
||||
|
||||
const ownsLaunchTerminal = this._beginSessionLaunchStatus(
|
||||
`Starting ${shellCount} Shell session(s) in ${caseName}...`,
|
||||
@@ -3916,42 +3916,13 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
showMobileCasePicker() {
|
||||
const modal = document.getElementById('mobileCasePickerModal');
|
||||
const search = document.getElementById('mobileCaseSearch');
|
||||
|
||||
// Every open starts unfiltered: the sheet is a one-shot picker, and a query
|
||||
// left over from last time would present a truncated case list as the whole
|
||||
// one. Deliberately no autofocus: focusing raises the keyboard over a sheet
|
||||
// that is anchored to the bottom of the screen, so the user asks for it.
|
||||
this._mobileCaseFilter = '';
|
||||
if (search) search.value = '';
|
||||
|
||||
this.renderMobileCaseList();
|
||||
modal.classList.add('active');
|
||||
},
|
||||
|
||||
/** Re-render the sheet's list for the current search text. */
|
||||
renderMobileCaseList() {
|
||||
const listContainer = document.getElementById('mobileCaseList');
|
||||
const select = document.getElementById('quickStartCase');
|
||||
if (!listContainer || !select) return;
|
||||
const currentCase = select.value;
|
||||
|
||||
const clearBtn = document.getElementById('mobileCaseSearchClear');
|
||||
const filter = this._mobileCaseFilter || '';
|
||||
if (clearBtn) clearBtn.hidden = filter.length === 0;
|
||||
|
||||
// Same matcher the desktop combobox uses (every term must appear in the
|
||||
// option's searchText, which carries the name, label, path and the
|
||||
// remote/docker fields), so both pickers answer a query identically.
|
||||
const allCases = this.filterCasePickerOptions(this.getCasePickerOptions(), filter);
|
||||
|
||||
if (allCases.length === 0) {
|
||||
listContainer.innerHTML = '<div class="mobile-case-empty">No cases match</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Build case list HTML
|
||||
let html = '';
|
||||
const allCases = this.getCasePickerOptions();
|
||||
|
||||
for (const c of allCases) {
|
||||
const isSelected = c.name === currentCase;
|
||||
@@ -3979,43 +3950,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
|
||||
listContainer.innerHTML = html;
|
||||
},
|
||||
|
||||
/** oninput on the sheet's search field. */
|
||||
filterMobileCaseList() {
|
||||
const search = document.getElementById('mobileCaseSearch');
|
||||
this._mobileCaseFilter = search?.value || '';
|
||||
this.renderMobileCaseList();
|
||||
},
|
||||
|
||||
clearMobileCaseSearch() {
|
||||
const search = document.getElementById('mobileCaseSearch');
|
||||
if (search) search.value = '';
|
||||
this._mobileCaseFilter = '';
|
||||
this.renderMobileCaseList();
|
||||
search?.focus();
|
||||
},
|
||||
|
||||
handleMobileCaseSearchKeydown(event) {
|
||||
if (event.key === 'Enter') {
|
||||
// A search that narrowed to one case is an unambiguous choice, so Enter
|
||||
// takes it instead of leaving the user to reach past the keyboard for a
|
||||
// single row. Several matches just dismiss the keyboard.
|
||||
event.preventDefault();
|
||||
const matches = this.filterCasePickerOptions(this.getCasePickerOptions(), this._mobileCaseFilter || '');
|
||||
if (matches.length === 1) {
|
||||
this.selectMobileCase(matches[0].name);
|
||||
} else {
|
||||
event.target?.blur?.();
|
||||
}
|
||||
} else if (event.key === 'Escape') {
|
||||
// Swallowed: the document-level Escape handler closes the whole sheet, and
|
||||
// the first Escape here means "drop the filter", not "give up on picking".
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (this._mobileCaseFilter) this.clearMobileCaseSearch();
|
||||
else this.closeMobileCasePicker();
|
||||
}
|
||||
modal.classList.add('active');
|
||||
},
|
||||
|
||||
closeMobileCasePicker() {
|
||||
|
||||
@@ -6834,99 +6834,6 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Search row. Tokens only (no literal dark glass) so the light skins need no
|
||||
override of their own; the sheet itself is already re-pointed at
|
||||
var(--floating-bg) up in the skin block. */
|
||||
.mobile-case-picker-search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 12px 20px 4px;
|
||||
padding: 0 10px;
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.mobile-case-picker-search:focus-within {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.mobile-case-search-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-dim);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mobile-case-search-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
/* 16px: anything smaller makes iOS Safari zoom the page on focus, which
|
||||
leaves the sheet scrolled off-centre when the field is blurred again. */
|
||||
font-size: 16px;
|
||||
font-family: inherit;
|
||||
padding: 11px 0;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.mobile-case-search-input::placeholder {
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
/* The global input:focus-visible rule paints a 1px accent ring, which inside
|
||||
an already-bordered row draws a second border a few pixels in. The row's
|
||||
:focus-within border is the focus cue here, so the input drops its own. */
|
||||
.mobile-case-search-input:focus-visible {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* The native affordance sits in a different spot per engine and is absent on
|
||||
Android, so the sheet ships its own clear button and hides this one. */
|
||||
.mobile-case-search-input::-webkit-search-cancel-button,
|
||||
.mobile-case-search-input::-webkit-search-decoration {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
.mobile-case-search-clear {
|
||||
flex-shrink: 0;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--text-dim);
|
||||
font-size: 1.2rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mobile-case-search-clear:active {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* The UA's [hidden] rule is display:none at specificity (0,0,0) and loses to the
|
||||
display:flex above, so the button has to hide itself explicitly. */
|
||||
.mobile-case-search-clear[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mobile-case-empty {
|
||||
padding: 22px 20px;
|
||||
text-align: center;
|
||||
color: var(--text-dim);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.mobile-case-picker-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
|
||||
@@ -3543,6 +3543,29 @@ Object.assign(CodemanApp.prototype, {
|
||||
this._sendInputAsync(this.activeSessionId, text);
|
||||
},
|
||||
|
||||
/**
|
||||
* Re-assert a history anchor captured before a terminal write (#358).
|
||||
*
|
||||
* Called from xterm's write callback, never synchronously after write():
|
||||
* xterm parses on its own schedule, so the buffer only carries the redraw's
|
||||
* effect once that callback fires. A null anchor means the user was following
|
||||
* live output and nothing needs restoring.
|
||||
*/
|
||||
_restoreTerminalViewport(preserveViewportY, sessionId) {
|
||||
if (preserveViewportY === null || preserveViewportY === undefined) return;
|
||||
// The anchor is a row index into the buffer it was captured from. Now that
|
||||
// this runs a parse later instead of synchronously, a session switch can land
|
||||
// in between: selectSession() resets the terminal and chunk-loads the new
|
||||
// session's scrollback, and scrolling THAT buffer to a row that meant
|
||||
// something in the previous one is not a restore, it is a jump to an
|
||||
// arbitrary place. Both checks cover one half of that window.
|
||||
if (sessionId !== undefined && sessionId !== this.activeSessionId) return;
|
||||
if (this._isLoadingBuffer) return;
|
||||
if (typeof this.terminal?.scrollToLine !== 'function') return;
|
||||
if (this.terminal.buffer?.active?.viewportY === preserveViewportY) return;
|
||||
this.terminal.scrollToLine(preserveViewportY);
|
||||
},
|
||||
|
||||
/**
|
||||
* Flush pending writes to terminal, processing DEC 2026 sync markers.
|
||||
* Strips markers and writes content atomically within a single frame.
|
||||
@@ -3578,6 +3601,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
// scroll-to-bottom below, where it protects against a mid-flush race.
|
||||
const preserveViewportY =
|
||||
this.terminal.buffer?.active && !this.isTerminalAtBottom() ? this.terminal.buffer.active.viewportY : null;
|
||||
// Which buffer the anchor belongs to, checked again when the write parses.
|
||||
const flushSessionId = this.activeSessionId;
|
||||
|
||||
const writeChunk = joined.slice(0, MAX_FRAME_BYTES);
|
||||
if (_joinedLen > MAX_FRAME_BYTES) {
|
||||
@@ -3592,6 +3617,16 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.terminal.write(writeChunk, () => {
|
||||
this._terminalWriteInFlight = false;
|
||||
this._terminalWriteInFlightBytes = 0;
|
||||
// Restore INSIDE the callback (#358). xterm parses asynchronously, so
|
||||
// the moment write() returns the buffer has not moved yet: the old
|
||||
// restore ran here, found viewportY still equal to the anchor, and did
|
||||
// nothing at all — then the parse landed and a cursor-addressed Codex
|
||||
// redraw dragged the viewport to the live bottom with nothing left to
|
||||
// pull it back. The callback is xterm's own "this chunk is parsed"
|
||||
// signal, which is the earliest point the anchor can actually be
|
||||
// reasserted. (The synchronous version passed its regression test only
|
||||
// because the test's write mock moved the viewport synchronously.)
|
||||
this._restoreTerminalViewport(preserveViewportY, flushSessionId);
|
||||
this._scheduleTerminalWriteFlush();
|
||||
});
|
||||
} catch (err) {
|
||||
@@ -3599,13 +3634,6 @@ Object.assign(CodemanApp.prototype, {
|
||||
this._terminalWriteInFlightBytes = 0;
|
||||
throw err;
|
||||
}
|
||||
if (
|
||||
preserveViewportY !== null &&
|
||||
this.terminal.buffer?.active?.viewportY !== preserveViewportY &&
|
||||
typeof this.terminal.scrollToLine === 'function'
|
||||
) {
|
||||
this.terminal.scrollToLine(preserveViewportY);
|
||||
}
|
||||
const bytesThisFrame = deferred ? MAX_FRAME_BYTES : _joinedLen;
|
||||
const _dt = performance.now() - _t0;
|
||||
if (_dt > 100 || deferred)
|
||||
@@ -3617,7 +3645,13 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Give manual scroll-up gestures a short grace window so high-frequency
|
||||
// Codex status ticks do not snap the viewport back while the user is
|
||||
// trying to inspect earlier output.
|
||||
if (this._wasAtBottomBeforeWrite && !this._hasRecentUserScrollUp()) {
|
||||
//
|
||||
// A live anchor wins outright. The two flags are captured at different
|
||||
// moments (_wasAtBottomBeforeWrite at the frame's first batchTerminalWrite,
|
||||
// the anchor at flush time), so a scroll-up in between leaves both set; now
|
||||
// that the anchor is reasserted after the parse, running both would jump to
|
||||
// the bottom and then back one frame later instead of simply staying put.
|
||||
if (preserveViewportY === null && this._wasAtBottomBeforeWrite && !this._hasRecentUserScrollUp()) {
|
||||
this.terminal.scrollToBottom();
|
||||
}
|
||||
|
||||
|
||||
+1
-177
@@ -691,9 +691,7 @@ describe('case selector refresh', () => {
|
||||
it('creates remote shell sessions by caseName instead of remote display path', async () => {
|
||||
const elements: Record<string, any> = {
|
||||
quickStartCase: { value: 'gpu-work' },
|
||||
// The toolbar's one instance stepper, shared by Run and Run Shell since
|
||||
// the second (#shellCount) group was removed.
|
||||
tabCount: { value: '1' },
|
||||
shellCount: { value: '1' },
|
||||
};
|
||||
const requests: Array<{ url: string; body?: any }> = [];
|
||||
const CodemanApp = function CodemanApp(this: any) {};
|
||||
@@ -830,180 +828,6 @@ describe('case selector refresh', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('mobile case picker search', () => {
|
||||
// The phone bottom sheet listed every case with no way to narrow it, while the
|
||||
// desktop toolbar combobox has filtered for a while. Both now run the same
|
||||
// matcher (filterCasePickerOptions), so these assert the sheet's own wiring:
|
||||
// the reset-on-open, the rendered rows, the empty state and the Enter shortcut.
|
||||
function loadMobilePicker(cases: any[], selected = 'testcase') {
|
||||
const elements: Record<string, any> = {};
|
||||
const CodemanApp = function CodemanApp(this: any) {};
|
||||
const context = vm.createContext({
|
||||
CodemanApp,
|
||||
localStorage: { getItem: () => null, setItem: () => {} },
|
||||
document: { getElementById: (id: string) => elements[id] ?? null },
|
||||
console,
|
||||
escapeHtml: (value: string) => value,
|
||||
});
|
||||
const sessionUi = readFileSync(resolve(import.meta.dirname, '../src/web/public/session-ui.js'), 'utf8');
|
||||
vm.runInContext(sessionUi, context, { filename: 'session-ui.js' });
|
||||
|
||||
const classes = new Set<string>();
|
||||
elements.mobileCasePickerModal = {
|
||||
classList: { add: (c: string) => classes.add(c), remove: (c: string) => classes.delete(c) },
|
||||
};
|
||||
elements.mobileCaseList = { innerHTML: '' };
|
||||
elements.mobileCaseSearch = { value: '', focus: () => {} };
|
||||
elements.mobileCaseSearchClear = { hidden: true };
|
||||
elements.quickStartCase = { value: selected };
|
||||
|
||||
const app = new (CodemanApp as any)();
|
||||
app.cases = cases;
|
||||
app.updateDirDisplayForCase = () => {};
|
||||
app.updateMobileCaseLabel = () => {};
|
||||
app.saveLastUsedCase = () => {};
|
||||
app.showToast = () => {};
|
||||
return { app, elements, classes };
|
||||
}
|
||||
|
||||
const renderedNames = (elements: Record<string, any>) =>
|
||||
[...String(elements.mobileCaseList.innerHTML).matchAll(/mobile-case-item-name">([^<]*)</g)].map((m) => m[1]);
|
||||
|
||||
const cases = [
|
||||
{ name: 'alpha-api' },
|
||||
{ name: 'claudeman' },
|
||||
{ name: 'claudeman-docs' },
|
||||
{ name: 'moneytrove', location: 'remote', remote: { hostId: 'mac-mini', path: '/Users/x/moneytrove' } },
|
||||
];
|
||||
|
||||
it('lists every case on open and leaves the search field empty', () => {
|
||||
const { app, elements, classes } = loadMobilePicker(cases);
|
||||
elements.mobileCaseSearch.value = 'stale query';
|
||||
app._mobileCaseFilter = 'stale query';
|
||||
|
||||
app.showMobileCasePicker();
|
||||
|
||||
expect(classes.has('active')).toBe(true);
|
||||
expect(elements.mobileCaseSearch.value).toBe('');
|
||||
expect(elements.mobileCaseSearchClear.hidden).toBe(true);
|
||||
// testcase is synthesized by buildCasePickerOptions when absent.
|
||||
expect(renderedNames(elements)).toEqual([
|
||||
'alpha-api',
|
||||
'claudeman',
|
||||
'claudeman-docs',
|
||||
'moneytrove @ mac-mini',
|
||||
'testcase',
|
||||
]);
|
||||
});
|
||||
|
||||
it('narrows the rendered rows to the query and reveals the clear button', () => {
|
||||
const { app, elements } = loadMobilePicker(cases);
|
||||
app.showMobileCasePicker();
|
||||
|
||||
elements.mobileCaseSearch.value = 'claud';
|
||||
app.filterMobileCaseList();
|
||||
|
||||
expect(renderedNames(elements)).toEqual(['claudeman', 'claudeman-docs']);
|
||||
expect(elements.mobileCaseSearchClear.hidden).toBe(false);
|
||||
|
||||
// Same searchText the desktop combobox indexes, so a remote host matches too.
|
||||
elements.mobileCaseSearch.value = 'mac-mini';
|
||||
app.filterMobileCaseList();
|
||||
expect(renderedNames(elements)).toEqual(['moneytrove @ mac-mini']);
|
||||
});
|
||||
|
||||
it('renders an empty state rather than a blank sheet when nothing matches', () => {
|
||||
const { app, elements } = loadMobilePicker(cases);
|
||||
app.showMobileCasePicker();
|
||||
|
||||
elements.mobileCaseSearch.value = 'nothing-here';
|
||||
app.filterMobileCaseList();
|
||||
|
||||
expect(renderedNames(elements)).toEqual([]);
|
||||
expect(elements.mobileCaseList.innerHTML).toContain('No cases match');
|
||||
});
|
||||
|
||||
it('clears the filter back to the full list', () => {
|
||||
const { app, elements } = loadMobilePicker(cases);
|
||||
app.showMobileCasePicker();
|
||||
elements.mobileCaseSearch.value = 'claud';
|
||||
app.filterMobileCaseList();
|
||||
|
||||
app.clearMobileCaseSearch();
|
||||
|
||||
expect(elements.mobileCaseSearch.value).toBe('');
|
||||
expect(elements.mobileCaseSearchClear.hidden).toBe(true);
|
||||
expect(renderedNames(elements)).toHaveLength(5);
|
||||
});
|
||||
|
||||
it('takes a single remaining match on Enter and leaves an ambiguous one alone', () => {
|
||||
const { app, elements, classes } = loadMobilePicker(cases);
|
||||
app.showMobileCasePicker();
|
||||
|
||||
// Two matches: Enter only dismisses the keyboard.
|
||||
elements.mobileCaseSearch.value = 'claud';
|
||||
app.filterMobileCaseList();
|
||||
let blurred = false;
|
||||
app.handleMobileCaseSearchKeydown({
|
||||
key: 'Enter',
|
||||
preventDefault: () => {},
|
||||
stopPropagation: () => {},
|
||||
target: {
|
||||
blur: () => {
|
||||
blurred = true;
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(blurred).toBe(true);
|
||||
expect(classes.has('active')).toBe(true);
|
||||
expect(elements.quickStartCase.value).toBe('testcase');
|
||||
|
||||
// One match: Enter picks it and closes the sheet.
|
||||
elements.mobileCaseSearch.value = 'claudeman-d';
|
||||
app.filterMobileCaseList();
|
||||
app.handleMobileCaseSearchKeydown({
|
||||
key: 'Enter',
|
||||
preventDefault: () => {},
|
||||
stopPropagation: () => {},
|
||||
target: { blur: () => {} },
|
||||
});
|
||||
expect(elements.quickStartCase.value).toBe('claudeman-docs');
|
||||
expect(classes.has('active')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toolbar instance count', () => {
|
||||
// Run Shell used to carry its own `#shellCount` stepper next to the Run one.
|
||||
// It was removed, so both launch paths read #tabCount, and an absent stepper
|
||||
// (phones and tablets hide the group) has to read as 1, not throw.
|
||||
function loadCounter(elements: Record<string, any>) {
|
||||
const CodemanApp = function CodemanApp(this: any) {};
|
||||
const context = vm.createContext({
|
||||
CodemanApp,
|
||||
localStorage: { getItem: () => null, setItem: () => {} },
|
||||
document: { getElementById: (id: string) => elements[id] ?? null },
|
||||
console,
|
||||
});
|
||||
const sessionUi = readFileSync(resolve(import.meta.dirname, '../src/web/public/session-ui.js'), 'utf8');
|
||||
vm.runInContext(sessionUi, context, { filename: 'session-ui.js' });
|
||||
return new (CodemanApp as any)();
|
||||
}
|
||||
|
||||
it('reads the shared stepper and falls back to 1 when it is absent', () => {
|
||||
expect(loadCounter({ tabCount: { value: '3' } })._toolbarInstanceCount()).toBe(3);
|
||||
expect(loadCounter({})._toolbarInstanceCount()).toBe(1);
|
||||
expect(loadCounter({ tabCount: { value: '' } })._toolbarInstanceCount()).toBe(1);
|
||||
expect(loadCounter({ tabCount: { value: '0' } })._toolbarInstanceCount()).toBe(1);
|
||||
expect(loadCounter({ tabCount: { value: '99' } })._toolbarInstanceCount()).toBe(20);
|
||||
});
|
||||
|
||||
it('no longer exposes the removed shell stepper handlers', () => {
|
||||
const app = loadCounter({ tabCount: { value: '1' } });
|
||||
expect(app.incrementShellCount).toBeUndefined();
|
||||
expect(app.decrementShellCount).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Gemini quick start', () => {
|
||||
// Regression guard for the ApiResponse-envelope unwrap in runGemini(): the
|
||||
// status check must read `.data.available` and the quick-start response must
|
||||
|
||||
@@ -49,6 +49,39 @@ function loadTerminalUiHarness(mode: string) {
|
||||
return { app, writes };
|
||||
}
|
||||
|
||||
/**
|
||||
* Swap in a terminal whose write() parses ASYNCHRONOUSLY, the way xterm.js does.
|
||||
*
|
||||
* The real renderer queues the chunk and applies it later, firing the write
|
||||
* callback once it has been parsed; a redraw that addresses a row past the
|
||||
* viewport (Codex's status line) drags the viewport to the live bottom at that
|
||||
* point, not when write() returns. `parse()` runs that pending work.
|
||||
*/
|
||||
function attachAsyncParsingTerminal(app: any, opts: { viewportY: number; baseY: number }) {
|
||||
const buffer = { viewportY: opts.viewportY, baseY: opts.baseY };
|
||||
const pending: Array<() => void> = [];
|
||||
app.terminal.buffer = { active: buffer };
|
||||
app.terminal.write = vi.fn((_data: string, callback?: () => void) => {
|
||||
pending.push(() => {
|
||||
buffer.viewportY = buffer.baseY; // the redraw lands
|
||||
callback?.();
|
||||
});
|
||||
});
|
||||
app.terminal.scrollToLine = vi.fn((line: number) => {
|
||||
buffer.viewportY = line;
|
||||
});
|
||||
app.terminal.scrollToBottom = vi.fn(() => {
|
||||
buffer.viewportY = buffer.baseY;
|
||||
});
|
||||
return {
|
||||
buffer,
|
||||
parse: () => {
|
||||
const queued = pending.splice(0, pending.length);
|
||||
for (const run of queued) run();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function loadAppHarness() {
|
||||
const dir = resolve(import.meta.dirname, '../src/web/public');
|
||||
const fetchMock = vi.fn();
|
||||
@@ -337,20 +370,111 @@ describe('terminal flush budget', () => {
|
||||
|
||||
it('restores the user scroll position when Codex Working redraws move the viewport', () => {
|
||||
const { app } = loadTerminalUiHarness('codex');
|
||||
const buffer = { viewportY: 40, baseY: 100 };
|
||||
app.terminal.buffer = { active: buffer };
|
||||
app.terminal.write = vi.fn(() => {
|
||||
buffer.viewportY = buffer.baseY;
|
||||
});
|
||||
app.terminal.scrollToLine = vi.fn((line: number) => {
|
||||
buffer.viewportY = line;
|
||||
});
|
||||
const { buffer, parse } = attachAsyncParsingTerminal(app, { viewportY: 40, baseY: 100 });
|
||||
app._wasAtBottomBeforeWrite = true;
|
||||
app._lastUserScrollUpAt = 0;
|
||||
app.pendingWrites.push('\x1b[55;1H\x1b[2m• Working (6s)');
|
||||
|
||||
app.flushPendingWrites();
|
||||
parse();
|
||||
|
||||
expect(buffer.viewportY).toBe(40);
|
||||
});
|
||||
|
||||
// Issue #358. xterm.js parses on its own schedule, so the buffer still holds
|
||||
// the pre-write viewport the instant write() returns: restoring there compared
|
||||
// the anchor against itself, did nothing, and left the redraw free to drag the
|
||||
// viewport to the live bottom a tick later. The previous regression passed
|
||||
// because its write mock moved the viewport synchronously, which real xterm
|
||||
// never does. These drive the callback explicitly instead.
|
||||
it('restores the history anchor only AFTER xterm has parsed the write (#358)', () => {
|
||||
const { app } = loadTerminalUiHarness('codex');
|
||||
const { buffer, parse } = attachAsyncParsingTerminal(app, { viewportY: 40, baseY: 100 });
|
||||
app.pendingWrites.push('\x1b[55;1H\x1b[2m• Working (6s)');
|
||||
|
||||
app.flushPendingWrites();
|
||||
// Nothing has parsed yet, so nothing may have been restored yet either.
|
||||
expect(app.terminal.scrollToLine).not.toHaveBeenCalled();
|
||||
expect(buffer.viewportY).toBe(40);
|
||||
|
||||
parse();
|
||||
|
||||
expect(app.terminal.scrollToLine).toHaveBeenCalledWith(40);
|
||||
expect(buffer.viewportY).toBe(40);
|
||||
});
|
||||
|
||||
it('holds the anchor across consecutive Codex redraws', () => {
|
||||
const { app } = loadTerminalUiHarness('codex');
|
||||
const { buffer, parse } = attachAsyncParsingTerminal(app, { viewportY: 40, baseY: 100 });
|
||||
|
||||
for (const frame of ['\x1b[55;1H\x1b[2m• Working (6s)', '\x1b[55;1H\x1b[2m• Working (7s)']) {
|
||||
app.pendingWrites.push(frame);
|
||||
app.flushPendingWrites();
|
||||
parse();
|
||||
expect(buffer.viewportY).toBe(40);
|
||||
}
|
||||
});
|
||||
|
||||
it('holds the anchor across a chunked write whose remainder is deferred', () => {
|
||||
const { app } = loadTerminalUiHarness('codex');
|
||||
const { buffer, parse } = attachAsyncParsingTerminal(app, { viewportY: 40, baseY: 100 });
|
||||
// Over the 32KB codex frame budget, so the flush defers a remainder and the
|
||||
// second chunk goes out from the write callback's reschedule.
|
||||
app.pendingWrites.push('x'.repeat(40000));
|
||||
|
||||
app.flushPendingWrites();
|
||||
parse();
|
||||
expect(buffer.viewportY).toBe(40);
|
||||
|
||||
app.flushPendingWrites();
|
||||
parse();
|
||||
expect(buffer.viewportY).toBe(40);
|
||||
expect(app.pendingWrites).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('drops the anchor when the user switched sessions before the write parsed', () => {
|
||||
// The anchor indexes the buffer it came from. selectSession() resets the
|
||||
// terminal and chunk-loads a different scrollback, so replaying row 40 into
|
||||
// that one is a jump to an arbitrary place, not a restore. Only reachable now
|
||||
// that the restore runs a parse later than the write.
|
||||
const { app } = loadTerminalUiHarness('codex');
|
||||
const { buffer, parse } = attachAsyncParsingTerminal(app, { viewportY: 40, baseY: 100 });
|
||||
app.pendingWrites.push('\x1b[55;1H\x1b[2m• Working (6s)');
|
||||
|
||||
app.flushPendingWrites();
|
||||
app.activeSessionId = 'session-2'; // the user clicked another tab
|
||||
parse();
|
||||
|
||||
expect(app.terminal.scrollToLine).not.toHaveBeenCalled();
|
||||
expect(buffer.viewportY).toBe(buffer.baseY);
|
||||
});
|
||||
|
||||
it('drops the anchor while a buffer load is replaying history', () => {
|
||||
const { app } = loadTerminalUiHarness('codex');
|
||||
const { parse } = attachAsyncParsingTerminal(app, { viewportY: 40, baseY: 100 });
|
||||
app.pendingWrites.push('\x1b[55;1H\x1b[2m• Working (6s)');
|
||||
|
||||
app.flushPendingWrites();
|
||||
app._isLoadingBuffer = true; // chunkedTerminalWrite owns the viewport now
|
||||
parse();
|
||||
|
||||
expect(app.terminal.scrollToLine).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not bounce off the bottom when the sticky flag and an anchor disagree', () => {
|
||||
// _wasAtBottomBeforeWrite is captured at the frame's first batchTerminalWrite
|
||||
// and the anchor at flush time, so a scroll-up in between leaves both live.
|
||||
// The anchor wins: scrolling to the bottom and back would be a visible jump.
|
||||
const { app } = loadTerminalUiHarness('codex');
|
||||
const { buffer, parse } = attachAsyncParsingTerminal(app, { viewportY: 40, baseY: 100 });
|
||||
app._wasAtBottomBeforeWrite = true;
|
||||
app._lastUserScrollUpAt = 0;
|
||||
app.pendingWrites.push('\x1b[55;1H\x1b[2m• Working (6s)');
|
||||
|
||||
app.flushPendingWrites();
|
||||
parse();
|
||||
|
||||
expect(app.terminal.scrollToBottom).not.toHaveBeenCalled();
|
||||
expect(buffer.viewportY).toBe(40);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user