fix: overlay rendering — vertical alignment, line artifact, and duplicate class members

Overlay renderer (xterm-zerolag-input):
- Add charTop/charHeight to CellDimensions and RenderParams for precise
  vertical text positioning matching xterm's canvas renderer
- Convert device.char dimensions to CSS pixels via devicePixelRatio
- Extend line div background 1px past cell boundary to cover compositing
  seam between overlay layer (z-index:7) and canvas layer below
- Remove -webkit-font-smoothing/text-rendering overrides that made overlay
  text thinner than canvas text
- Add per-span height/lineHeight for natural CSS vertical centering
- Add setPrompt() method for runtime prompt strategy switching (fixes tab
  switching crash with "setPrompt is not a function")

app.js duplicate class members:
- Remove dead formatTokens duplicate (line ~5590 shadowed precise version)
- Remove fire-and-forget resetCircuitBreaker duplicate (shadowed notification version)
- Rename mux-panel killAllSessions to killAllMuxSessions (was shadowing
  Codeman session killer, breaking Ctrl+K)

Other:
- Update index.html onclick to use killAllMuxSessions
- Add getTeamTasks mock to test route context

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
arkon
2026-03-01 16:27:40 +01:00
co-authored by Claude Opus 4.6
parent 3915f4ea9d
commit e8fd358923
7 changed files with 60 additions and 29 deletions
@@ -4,18 +4,26 @@ import type { XtermTerminal, CellDimensions } from './types.js';
* Get cell dimensions from the terminal, handling xterm.js v5 (private API) * Get cell dimensions from the terminal, handling xterm.js v5 (private API)
* and v7+ (public API). * and v7+ (public API).
* *
* Returns CSS-pixel values. xterm's `device.char` is in device pixels, so
* we divide by `devicePixelRatio` to stay consistent with `css.cell`.
*
* Returns `null` if the terminal is not yet rendered or dimensions are * Returns `null` if the terminal is not yet rendered or dimensions are
* unavailable. * unavailable.
*/ */
export function getCellDimensions(terminal: XtermTerminal): CellDimensions | null { export function getCellDimensions(terminal: XtermTerminal): CellDimensions | null {
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
const t = terminal as any; const t = terminal as any;
const dpr = typeof devicePixelRatio === 'number' && devicePixelRatio > 0
? devicePixelRatio : 1;
// Try v7+ public API first // Try v7+ public API first
if (t.dimensions?.css?.cell) { if (t.dimensions?.css?.cell) {
const cellH = t.dimensions.css.cell.height;
return { return {
width: t.dimensions.css.cell.width, width: t.dimensions.css.cell.width,
height: t.dimensions.css.cell.height, height: cellH,
charTop: (t.dimensions?.device?.char?.top ?? 0) / dpr,
charHeight: (t.dimensions?.device?.char?.height ?? (cellH * dpr)) / dpr,
}; };
} }
@@ -23,9 +31,12 @@ export function getCellDimensions(terminal: XtermTerminal): CellDimensions | nul
try { try {
const dims = t._core?._renderService?.dimensions; const dims = t._core?._renderService?.dimensions;
if (dims?.css?.cell) { if (dims?.css?.cell) {
const cellH = dims.css.cell.height;
return { return {
width: dims.css.cell.width, width: dims.css.cell.width,
height: dims.css.cell.height, height: cellH,
charTop: (dims.device?.char?.top ?? 0) / dpr,
charHeight: (dims.device?.char?.height ?? (cellH * dpr)) / dpr,
}; };
} }
} catch { } catch {
@@ -8,9 +8,9 @@ import type { RenderParams, FontStyle } from './types.js';
* occurs with normal DOM text flow. * occurs with normal DOM text flow.
*/ */
export function renderOverlay(container: HTMLDivElement, params: RenderParams): void { export function renderOverlay(container: HTMLDivElement, params: RenderParams): void {
const { lines, startCol, totalCols, cellW, cellH, promptRow, font, showCursor, cursorColor } = params; const { lines, startCol, totalCols, cellW, cellH, charTop, charHeight, promptRow, font, showCursor, cursorColor } = params;
// Position container at prompt row // Position container at prompt row.
container.style.left = '0px'; container.style.left = '0px';
container.style.top = (promptRow * cellH) + 'px'; container.style.top = (promptRow * cellH) + 'px';
@@ -22,7 +22,7 @@ export function renderOverlay(container: HTMLDivElement, params: RenderParams):
const leftPx = i === 0 ? startCol * cellW : 0; const leftPx = i === 0 ? startCol * cellW : 0;
const widthPx = i === 0 ? (fullWidthPx - leftPx) : fullWidthPx; const widthPx = i === 0 ? (fullWidthPx - leftPx) : fullWidthPx;
const topPx = i * cellH; const topPx = i * cellH;
const lineEl = makeLine(lines[i], leftPx, topPx, widthPx, cellH, cellW, font); const lineEl = makeLine(lines[i], leftPx, topPx, widthPx, cellH, cellW, charTop, charHeight, font);
container.appendChild(lineEl); container.appendChild(lineEl);
} }
@@ -60,6 +60,8 @@ function makeLine(
widthPx: number, widthPx: number,
cellH: number, cellH: number,
cellW: number, cellW: number,
charTop: number,
charHeight: number,
font: FontStyle, font: FontStyle,
): HTMLDivElement { ): HTMLDivElement {
const el = document.createElement('div'); const el = document.createElement('div');
@@ -68,21 +70,28 @@ function makeLine(
el.style.left = leftPx + 'px'; el.style.left = leftPx + 'px';
el.style.top = topPx + 'px'; el.style.top = topPx + 'px';
el.style.width = widthPx + 'px'; el.style.width = widthPx + 'px';
// Extend background 1px past cell boundary to cover the compositing
// seam between the overlay layer (z-index:7) and the canvas layer below.
// The extra 1px lands in the next row's charTop gap (empty area before
// text rendering starts), so no canvas content is obscured.
el.style.height = (cellH + 1) + 'px'; el.style.height = (cellH + 1) + 'px';
el.style.lineHeight = cellH + 'px';
// Spans fill the full cell height with matching lineHeight for natural
// CSS vertical centering. No transform — any sub-pixel overhang past
// the line div causes visible anti-aliasing artifacts at the boundary.
// The ≤0.5px difference from canvas ceil() rounding is imperceptible.
for (let i = 0; i < text.length; i++) { for (let i = 0; i < text.length; i++) {
const span = document.createElement('span'); const span = document.createElement('span');
// Match xterm.js canvas text rendering: // No ligatures — canvas renders each glyph independently.
// - antialiased smoothing (canvas uses grayscale, not LCD subpixel)
// - geometricPrecision for consistent glyph sizing
// - no ligatures (canvas renders each glyph independently)
span.style.cssText = span.style.cssText =
'position:absolute;display:inline-block;text-align:center;pointer-events:none;' + 'position:absolute;display:inline-block;text-align:center;pointer-events:none;' +
'-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;' + "font-feature-settings:'liga' 0,'calt' 0";
"text-rendering:geometricPrecision;font-feature-settings:'liga' 0,'calt' 0";
span.style.left = (i * cellW) + 'px'; span.style.left = (i * cellW) + 'px';
span.style.top = '0px';
span.style.width = cellW + 'px'; span.style.width = cellW + 'px';
span.style.height = cellH + 'px';
span.style.lineHeight = cellH + 'px';
span.style.fontFamily = font.fontFamily; span.style.fontFamily = font.fontFamily;
span.style.fontSize = font.fontSize; span.style.fontSize = font.fontSize;
span.style.fontWeight = font.fontWeight; span.style.fontWeight = font.fontWeight;
@@ -137,6 +137,10 @@ export interface ZerolagInputState {
export interface CellDimensions { export interface CellDimensions {
width: number; width: number;
height: number; height: number;
/** Vertical offset (px) from cell top to where characters render. */
charTop: number;
/** Height of the character rendering area (px). */
charHeight: number;
} }
/** Parameters for the overlay renderer. */ /** Parameters for the overlay renderer. */
@@ -146,6 +150,10 @@ export interface RenderParams {
totalCols: number; totalCols: number;
cellW: number; cellW: number;
cellH: number; cellH: number;
/** Vertical offset (px) from cell top to character rendering area. */
charTop: number;
/** Height of the character rendering area (px). */
charHeight: number;
promptRow: number; promptRow: number;
font: FontStyle; font: FontStyle;
showCursor: boolean; showCursor: boolean;
@@ -380,6 +380,20 @@ export class ZerolagInputAddon implements XtermAddon {
this._bufferDetectDone = true; this._bufferDetectDone = true;
} }
// ─── Prompt configuration ──────────────────────────────────────────
/**
* Change the prompt detection strategy at runtime.
* Call this when switching between CLI modes (e.g., Claude Code vs OpenCode)
* that use different prompt characters.
*/
setPrompt(finder: PromptFinder): void {
this._options.prompt = finder;
this._lastPromptPos = null;
this._lastRenderKey = '';
if (this._pendingText || this._flushedOffset > 0) this._render();
}
// ─── Prompt utilities ───────────────────────────────────────────── // ─── Prompt utilities ─────────────────────────────────────────────
/** /**
@@ -531,7 +545,7 @@ export class ZerolagInputAddon implements XtermAddon {
return; return;
} }
const { width: cellW, height: cellH } = dims; const { width: cellW, height: cellH, charTop, charHeight } = dims;
const totalCols = this._terminal.cols; const totalCols = this._terminal.cols;
const offset = this._getPromptOffset(); const offset = this._getPromptOffset();
const startCol = activePrompt.col + offset; const startCol = activePrompt.col + offset;
@@ -581,6 +595,8 @@ export class ZerolagInputAddon implements XtermAddon {
totalCols, totalCols,
cellW, cellW,
cellH, cellH,
charTop,
charHeight,
promptRow: activePrompt.row, promptRow: activePrompt.row,
font: this._font, font: this._font,
showCursor: this._options.showCursor, showCursor: this._options.showCursor,
+1 -14
View File
@@ -5587,11 +5587,6 @@ class CodemanApp {
} }
} }
formatTokens(tokens) {
if (tokens >= 1000000) return `${(tokens / 1000000).toFixed(1)}M`;
if (tokens >= 1000) return `${(tokens / 1000).toFixed(1)}k`;
return String(tokens || 0);
}
formatDuration(ms) { formatDuration(ms) {
if (!ms || ms < 0) return '0s'; if (!ms || ms < 0) return '0s';
@@ -8091,14 +8086,6 @@ class CodemanApp {
cbContainer.onclick = () => this.resetCircuitBreaker(); cbContainer.onclick = () => this.resetCircuitBreaker();
} }
async resetCircuitBreaker() {
if (!this.activeSessionId) return;
try {
await this._apiPost(`/api/sessions/${this.activeSessionId}/ralph-circuit-breaker/reset`, {});
} catch (err) {
console.error('Failed to reset circuit breaker:', err);
}
}
updateRalphStats(loop, completed, total) { updateRalphStats(loop, completed, total) {
// Time stat // Time stat
@@ -10703,7 +10690,7 @@ class CodemanApp {
} }
} }
killAllSessions() { killAllMuxSessions() {
const count = this.muxSessions?.length || 0; const count = this.muxSessions?.length || 0;
if (count === 0) { if (count === 0) {
alert('No sessions to kill'); alert('No sessions to kill');
+1 -1
View File
@@ -436,7 +436,7 @@
<div class="monitor-panel-header" id="monitorPanelHeader"> <div class="monitor-panel-header" id="monitorPanelHeader">
<div class="monitor-panel-title">Monitor</div> <div class="monitor-panel-title">Monitor</div>
<div class="monitor-panel-actions"> <div class="monitor-panel-actions">
<button class="btn-toolbar btn-sm btn-danger" onclick="app.killAllSessions()" title="Kill all sessions and their tmux processes">Kill All</button> <button class="btn-toolbar btn-sm btn-danger" onclick="app.killAllMuxSessions()" title="Kill all sessions and their tmux processes">Kill All</button>
<button class="btn-icon-sm" onclick="app.reconcileMuxSessions()" title="Refresh tmux sessions">&#x21BB;</button> <button class="btn-icon-sm" onclick="app.reconcileMuxSessions()" title="Refresh tmux sessions">&#x21BB;</button>
<button class="btn-icon-sm" onclick="app.toggleMonitorDetach()" title="Detach panel" id="monitorDetachBtn">&#x29C9;</button> <button class="btn-icon-sm" onclick="app.toggleMonitorDetach()" title="Detach panel" id="monitorDetachBtn">&#x29C9;</button>
<button class="btn-icon-sm" onclick="app.toggleMonitorPanel()" title="Toggle panel" id="monitorToggleBtn">&#x25B2;</button> <button class="btn-icon-sm" onclick="app.toggleMonitorPanel()" title="Toggle panel" id="monitorToggleBtn">&#x25B2;</button>
+1 -1
View File
@@ -101,7 +101,7 @@ export function createMockRouteContext(options?: { sessionId?: string }) {
runSummaryTrackers: new Map(), runSummaryTrackers: new Map(),
activePlanOrchestrators: new Map(), activePlanOrchestrators: new Map(),
scheduledRuns: new Map(), scheduledRuns: new Map(),
teamWatcher: { getTeams: vi.fn(() => []), hasActiveTeammates: vi.fn(() => false) }, teamWatcher: { getTeams: vi.fn(() => []), getTeamTasks: vi.fn(() => []), hasActiveTeammates: vi.fn(() => false) },
tunnelManager: null, tunnelManager: null,
pushStore: null, pushStore: null,
startScheduledRun: vi.fn(), startScheduledRun: vi.fn(),