mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7b79d4207c | ||
|
|
28744a2761 |
@@ -1,5 +1,25 @@
|
||||
# aicodeman
|
||||
|
||||
## 1.3.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Fix terminal scroll-back in Claude sessions, especially on macOS trackpads (#154).
|
||||
- **Deterministic CLI version detection.** `cliVersion` was often `undefined` because it was scraped from the `Claude Code vX.Y.Z` startup banner, which newer Claude Code builds (2.1.187+) don't reliably print and resumed sessions never show. With the version unknown, wheel-forwarding to Claude's transcript was silently disabled — and since repaint-mode Claude keeps no local terminal scrollback, scrolling up reached nothing. A new `getClaudeCliVersion()` probe (`claude --version`, cached, local-only) seeds the version at session start so forwarding engages. Restored sessions pick it up on restart.
|
||||
- **Trackpad Shift+scroll.** The wheel handler now reads the dominant axis, so a macOS trackpad's Shift+two-finger scroll — which the browser reports as horizontal `deltaX` — reaches xterm's local scrollback instead of collapsing to a fixed one line per tick.
|
||||
- **Opt-out setting.** New per-device App Settings → Input → "Wheel Scrolls Local History" (default off) pins the plain wheel to local scrollback (the pre-#144 behavior) for shell and other non-repaint sessions.
|
||||
- **No more "queued bytes" flicker on scroll.** Wheel-scroll reports now use a fire-and-forget send path (seq-less input frame) instead of the durable exactly-once input queue, so they no longer appear in the pending-bytes connection indicator or churn localStorage. Keystrokes, taps, and clicks still use the durable queue.
|
||||
|
||||
## 1.3.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Make the Cron Jobs modal fully skin-aware and consistent with App Settings' design language.
|
||||
- **Fix white dropdowns:** `.form-select` had no `appearance` reset and the app set no `color-scheme`, so native `<select>` fields rendered as white OS widgets that ignored the active skin. Selects now use `appearance: none` with an opaque `var(--bg-input)` fill, a `var(--border)` outline, and a custom chevron, so they follow the skin (daylight `#202833`, OG `#1a1a1f`). This is on the shared `.form-select` class, so App Settings, Cron, and every other select match and are fixed together.
|
||||
- Set `color-scheme: dark` on `:root` so native select option popups, date/time pickers, and scrollbars render dark across all three (dark) skins instead of flashing white.
|
||||
- Themed the Cron date/time inputs with `var(--bg-input)` / `var(--border)` instead of hardcoded values.
|
||||
- Fixed the Cron toolbar: "+ New Job" / "Refresh" and the footer Save / Cancel now use the full `btn-toolbar` size (matching the App Settings footer), with a wider gap and a divider under the toolbar for better spacing.
|
||||
|
||||
## 1.3.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -56,7 +56,7 @@ When user says "COM":
|
||||
|
||||
CI runs `npm run check:lockfile` on every push/PR, so lockfile drift fails the build even if the `version-packages` script is bypassed.
|
||||
|
||||
**Version**: 1.3.1 (must match `package.json`)
|
||||
**Version**: 1.3.3 (must match `package.json`)
|
||||
|
||||
## Project Overview
|
||||
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "1.3.1",
|
||||
"version": "1.3.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "aicodeman",
|
||||
"version": "1.3.1",
|
||||
"version": "1.3.3",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "1.3.1",
|
||||
"version": "1.3.3",
|
||||
"description": "Mission control for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
|
||||
@@ -62,6 +62,7 @@ import {
|
||||
SPINNER_PATTERN,
|
||||
MAX_SESSION_TOKENS,
|
||||
execPattern,
|
||||
getClaudeCliVersion,
|
||||
} from './utils/index.js';
|
||||
import {
|
||||
MAX_TERMINAL_BUFFER_SIZE,
|
||||
@@ -1308,6 +1309,27 @@ export class Session extends EventEmitter {
|
||||
`[Session] Starting interactive ${modeLabel} session` + (this._useMux ? ` (with ${this._mux!.backend})` : '')
|
||||
);
|
||||
|
||||
// Seed the CLI version deterministically for LOCAL Claude sessions. The
|
||||
// banner scrape in parseClaudeCodeInfo() is unreliable — newer Claude Code
|
||||
// builds don't print "Claude Code vX.Y.Z" at startup and resumed sessions
|
||||
// never show it — which left cliVersion undefined and silently disabled
|
||||
// wheel-forwarding to Claude's own transcript (the only route to history in
|
||||
// repaint/alt-screen mode; issue #154). Remote sessions run claude on
|
||||
// another host, so a local probe wouldn't reflect their version — skip them
|
||||
// and let the banner scrape handle those. Cached process-wide, best-effort.
|
||||
if (this.mode === 'claude' && !this._remote && !this._cliVersion) {
|
||||
const probedVersion = getClaudeCliVersion();
|
||||
if (probedVersion) {
|
||||
this._cliVersion = probedVersion;
|
||||
this.emit('cliInfoUpdated', {
|
||||
version: this._cliVersion,
|
||||
model: this._cliModel,
|
||||
accountType: this._cliAccountType,
|
||||
latestVersion: this._cliLatestVersion,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// If mux wrapping is enabled, create or attach to a mux session
|
||||
if (this._useMux && this._mux) {
|
||||
try {
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
* @module utils/claude-cli-resolver
|
||||
*/
|
||||
|
||||
import { execSync } from 'node:child_process';
|
||||
import { execSync, execFileSync } from 'node:child_process';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { delimiter, dirname, join } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
@@ -83,3 +83,43 @@ export function getAugmentedPath(): string {
|
||||
_augmentedPath = currentPath;
|
||||
return _augmentedPath;
|
||||
}
|
||||
|
||||
/** Cached `claude --version` result: string = version, null = probed but unavailable, undefined = not probed */
|
||||
let _claudeVersion: string | null | undefined = undefined;
|
||||
|
||||
/**
|
||||
* Returns the installed Claude CLI version (e.g. `"2.1.210"`), or null if it
|
||||
* can't be determined. Runs `claude --version` once and caches the result.
|
||||
*
|
||||
* This is a deterministic alternative to scraping the interactive startup
|
||||
* banner (`parseClaudeCodeInfo` in session.ts): newer Claude Code builds don't
|
||||
* reliably print `Claude Code vX.Y.Z` at startup, and resumed sessions never
|
||||
* show it, which left `cliVersion` undefined and silently disabled features
|
||||
* gated on it (e.g. wheel-forwarding to Claude's transcript — issue #154).
|
||||
*/
|
||||
export function getClaudeCliVersion(): string | null {
|
||||
if (_claudeVersion !== undefined) return _claudeVersion;
|
||||
// Keep the test suite hermetic — never spawn a real `claude` subprocess under
|
||||
// vitest (matches IS_TEST_MODE in tmux-manager). Tests that need a version set
|
||||
// it on the session directly.
|
||||
if (process.env.VITEST) {
|
||||
_claudeVersion = null;
|
||||
return _claudeVersion;
|
||||
}
|
||||
try {
|
||||
const dir = findClaudeDir();
|
||||
const bin = dir ? join(dir, 'claude') : 'claude';
|
||||
// execFileSync (no shell) — the resolved path may contain spaces, and there
|
||||
// is no untrusted input, but avoid a shell either way.
|
||||
const out = execFileSync(bin, ['--version'], {
|
||||
encoding: 'utf-8',
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
env: { ...process.env, PATH: getAugmentedPath() },
|
||||
});
|
||||
const match = out.match(/(\d+\.\d+\.\d+)/);
|
||||
_claudeVersion = match ? match[1] : null;
|
||||
} catch {
|
||||
_claudeVersion = null;
|
||||
}
|
||||
return _claudeVersion;
|
||||
}
|
||||
|
||||
+1
-1
@@ -26,7 +26,7 @@ export { isSafePushEndpoint } from './push-endpoint-validation.js';
|
||||
export { stringSimilarity, fuzzyPhraseMatch, todoContentHash } from './string-similarity.js';
|
||||
export { assertNever } from './type-safety.js';
|
||||
export { wrapWithNice } from './nice-wrapper.js';
|
||||
export { findClaudeDir, getAugmentedPath } from './claude-cli-resolver.js';
|
||||
export { findClaudeDir, getAugmentedPath, getClaudeCliVersion } from './claude-cli-resolver.js';
|
||||
export { resolveOpenCodeDir } from './opencode-cli-resolver.js';
|
||||
export { resolveCodexDir, isCodexAvailable } from './codex-cli-resolver.js';
|
||||
export { resolveGeminiDir, isGeminiAvailable } from './gemini-cli-resolver.js';
|
||||
|
||||
@@ -2369,6 +2369,39 @@ class CodemanApp {
|
||||
this._reliableSend(sessionId, input, opts?.useMux === true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire-and-forget input for EPHEMERAL, loss-tolerant streams (e.g. wheel-scroll
|
||||
* reports). Unlike _sendInputAsync, this never enters the durable seq/ACK queue,
|
||||
* so it isn't persisted, retried, or counted in the pending-bytes connection
|
||||
* indicator (which was flickering "11b/22b queued" on every scroll tick). A
|
||||
* dropped scroll tick is harmless; keystrokes still go through _sendInputAsync.
|
||||
* The server applies a seq-less {t:'i'} frame / seq-less POST unconditionally
|
||||
* and sends no ACK (ws-routes.ts, session-routes input handler).
|
||||
*/
|
||||
_sendInputEphemeral(sessionId, input) {
|
||||
if (!sessionId || !input) return;
|
||||
if (this._ws && this._ws.readyState === WebSocket.OPEN && this._wsSessionId === sessionId) {
|
||||
try {
|
||||
this._ws.send(JSON.stringify({ t: 'i', d: input }));
|
||||
return;
|
||||
} catch {
|
||||
// socket died mid-send — fall through to a best-effort POST
|
||||
}
|
||||
}
|
||||
// No usable WS for this session: best-effort POST, not queued. Dropped on
|
||||
// failure — a scroll tick lost while offline needs no recovery.
|
||||
try {
|
||||
fetch(`/api/sessions/${sessionId}/input`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ input }),
|
||||
keepalive: true,
|
||||
}).catch(() => {});
|
||||
} catch {
|
||||
// ignore — loss-tolerant
|
||||
}
|
||||
}
|
||||
|
||||
/** Record one input frame and kick delivery. The record lives until ACKed. */
|
||||
_reliableSend(sessionId, data, useMux) {
|
||||
const seq = this._nextSeq(sessionId);
|
||||
|
||||
@@ -629,8 +629,8 @@
|
||||
<div class="modal-body">
|
||||
<p class="form-hint cron-modal-hint">Times use the server's local timezone.</p>
|
||||
<div class="cron-toolbar">
|
||||
<button class="btn-toolbar btn-sm btn-primary" onclick="app.openCronJobForm()">+ New Job</button>
|
||||
<button class="btn-toolbar btn-sm" onclick="app.refreshCron()">Refresh</button>
|
||||
<button class="btn-toolbar btn-primary" onclick="app.openCronJobForm()">+ New Job</button>
|
||||
<button class="btn-toolbar" onclick="app.refreshCron()">Refresh</button>
|
||||
</div>
|
||||
<!-- Job list -->
|
||||
<div id="cronJobList" class="cron-job-list"></div>
|
||||
@@ -720,8 +720,8 @@
|
||||
|
||||
<div id="cronFormError" class="form-hint cron-form-error"></div>
|
||||
<div class="cron-form-actions">
|
||||
<button class="btn-toolbar btn-sm" onclick="app.cancelCronJobForm()">Cancel</button>
|
||||
<button class="btn-toolbar btn-sm btn-primary" onclick="app.saveCronJob()">Save</button>
|
||||
<button class="btn-toolbar" onclick="app.cancelCronJobForm()">Cancel</button>
|
||||
<button class="btn-toolbar btn-primary" onclick="app.saveCronJob()">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1179,6 +1179,16 @@
|
||||
</div>
|
||||
<!-- Input Section -->
|
||||
<div class="settings-section-header">Input</div>
|
||||
<div class="settings-item settings-item-multiline" title="Scroll the terminal's own local scrollback with a plain mouse wheel / two-finger swipe, instead of forwarding the wheel to the CLI's transcript. Turn on if scrolling back through history doesn't work (e.g. macOS trackpad in Claude sessions). Shift+wheel always reaches local scrollback regardless.">
|
||||
<div class="settings-item-text">
|
||||
<span class="settings-item-label">Wheel Scrolls Local History</span>
|
||||
<span class="settings-item-desc">Plain wheel/trackpad pages the terminal scrollback</span>
|
||||
</div>
|
||||
<label class="switch switch-sm">
|
||||
<input type="checkbox" id="appSettingsTerminalWheelLocal">
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="settings-item settings-item-multiline" title="Shows typed characters instantly via overlay while forwarding keystrokes to the server in the background. Enables Tab completion, preserves input across tab switches, and protects against session crashes. Recommended for mobile and high-latency connections.">
|
||||
<div class="settings-item-text">
|
||||
<span class="settings-item-label">Local Echo</span>
|
||||
|
||||
@@ -337,6 +337,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
document.getElementById('appSettingsTunnelEnabled').checked = settings.tunnelEnabled ?? false;
|
||||
this.loadTunnelStatus();
|
||||
document.getElementById('appSettingsLocalEcho').checked = settings.localEchoEnabled ?? MobileDetection.isTouchDevice();
|
||||
document.getElementById('appSettingsTerminalWheelLocal').checked =
|
||||
settings.terminalWheelLocalScrollback ?? defaults.terminalWheelLocalScrollback ?? false;
|
||||
document.getElementById('appSettingsCjkInput').checked = settings.cjkInputEnabled ?? defaults.cjkInputEnabled ?? false;
|
||||
document.getElementById('appSettingsExtendedKeyboardBar').checked = settings.extendedKeyboardBar ?? false;
|
||||
document.getElementById('appSettingsTabTwoRows').checked = settings.tabTwoRows ?? defaults.tabTwoRows ?? false;
|
||||
@@ -1436,6 +1438,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
imageWatcherEnabled: document.getElementById('appSettingsImageWatcherEnabled').checked,
|
||||
tunnelEnabled: document.getElementById('appSettingsTunnelEnabled').checked,
|
||||
localEchoEnabled: document.getElementById('appSettingsLocalEcho').checked,
|
||||
terminalWheelLocalScrollback: document.getElementById('appSettingsTerminalWheelLocal').checked,
|
||||
cjkInputEnabled: document.getElementById('appSettingsCjkInput').checked,
|
||||
webglRendererEnabled: document.getElementById('appSettingsWebglRenderer').checked,
|
||||
extendedKeyboardBar: document.getElementById('appSettingsExtendedKeyboardBar').checked,
|
||||
@@ -1609,6 +1612,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
showPlanUsageLimits: _pul,
|
||||
showAttachmentsButton: _ahb,
|
||||
webglRendererEnabled: _wgl,
|
||||
terminalWheelLocalScrollback: _twls,
|
||||
...serverSettings
|
||||
} = settings;
|
||||
try {
|
||||
@@ -1775,6 +1779,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
ralphTrackerEnabled: false,
|
||||
tabTwoRows: false,
|
||||
cjkInputEnabled: false,
|
||||
terminalWheelLocalScrollback: false, // mobile scrolls via touch, not wheel
|
||||
webglRendererEnabled: false, // mobile always uses the DOM renderer
|
||||
skin: 'daylight-blue',
|
||||
};
|
||||
@@ -2149,6 +2154,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
'showMonitor', 'showProjectInsights', 'showFileBrowser', 'showSubagents',
|
||||
'subagentActiveTabOnly', 'tabTwoRows', 'localEchoEnabled', 'cjkInputEnabled', 'extendedKeyboardBar',
|
||||
'skin', 'showPlanUsageLimits', 'showAttachmentsButton', 'webglRendererEnabled',
|
||||
'terminalWheelLocalScrollback',
|
||||
]);
|
||||
// The plan-usage chip is a PER-DEVICE display setting (default OFF): desktop
|
||||
// can show it while mobile stays hidden. It used to sync, so an older
|
||||
|
||||
@@ -18,6 +18,10 @@
|
||||
}
|
||||
|
||||
:root {
|
||||
/* All three skins are dark — tell the UA to render native controls (select
|
||||
option popups, date/time pickers, scrollbars) in dark mode so they don't
|
||||
flash as white OS widgets. */
|
||||
color-scheme: dark;
|
||||
/* Carbon Aurora · Daylight — deep-but-not-black slate, surfaces step UP into the light */
|
||||
--bg-dark: #11151c;
|
||||
--bg-card: #1b222c;
|
||||
@@ -4186,6 +4190,24 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea {
|
||||
transition: border-color var(--transition-smooth), box-shadow var(--transition-smooth);
|
||||
}
|
||||
|
||||
/* Selects: strip the native (light) control so the field follows the active
|
||||
skin instead of rendering as a white OS widget. Opaque --bg-input fill +
|
||||
--border, a custom chevron, and color-scheme:dark (set on :root) for a dark
|
||||
option popup. Shared by App Settings, Cron, and every other .form-select. */
|
||||
.form-select {
|
||||
padding-right: 2rem;
|
||||
background-color: var(--bg-input);
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%2398a2b1' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 0.7rem center;
|
||||
background-size: 0.75rem;
|
||||
border-color: var(--border);
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.form-row input:focus,
|
||||
.form-row textarea:focus,
|
||||
.form-select:focus {
|
||||
@@ -11560,10 +11582,11 @@ html[data-skin="daylight-blue"] .welcome-btn-tunnel.active:hover {
|
||||
`.hidden`-toggled create form + conditional schedule/prompt rows never
|
||||
actually collapsed (all fields showed at once). Scope one here. */
|
||||
#cronModal .hidden { display: none !important; }
|
||||
.cron-modal-hint { margin-bottom: 0.75rem; }
|
||||
.cron-modal-hint { margin-bottom: 1rem; }
|
||||
.cron-toolbar {
|
||||
display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap;
|
||||
margin-bottom: 0.9rem;
|
||||
display: flex; gap: 0.6rem; align-items: center; flex-wrap: wrap;
|
||||
margin-bottom: 1.1rem; padding-bottom: 1.1rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* Job list — cards that echo the App Settings item look */
|
||||
@@ -11600,8 +11623,8 @@ html[data-skin="daylight-blue"] .welcome-btn-tunnel.active:hover {
|
||||
#cronModal .form-row input[type="datetime-local"],
|
||||
#cronModal .form-row input[type="time"] {
|
||||
width: 100%; padding: 0.5rem 0.75rem;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--btn-radius);
|
||||
color: var(--text); font-size: 0.8rem; font-family: inherit;
|
||||
transition: border-color var(--transition-smooth), box-shadow var(--transition-smooth);
|
||||
|
||||
@@ -385,7 +385,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
'wheel',
|
||||
(ev) => {
|
||||
ev.preventDefault();
|
||||
const lines = Math.round(ev.deltaY / 25) || (ev.deltaY > 0 ? 1 : -1);
|
||||
const lines = this._wheelScrollLines(ev);
|
||||
if (this._shouldForwardWheelToApp(ev)) {
|
||||
this._sendSyntheticSgrWheel(ev.clientX, ev.clientY, lines);
|
||||
return;
|
||||
@@ -2520,8 +2520,25 @@ Object.assign(CodemanApp.prototype, {
|
||||
// unknown version is treated as older). Gemini is a strip mode too but its
|
||||
// wheel behavior is unverified, so it keeps the local wheel — taps/clicks
|
||||
// are still forwarded for it (harmless no-ops at worst).
|
||||
// Wheel delta → whole scroll lines. macOS trackpads turn Shift+two-finger
|
||||
// scroll into a HORIZONTAL wheel (deltaY≈0, deltaX carries the magnitude), and
|
||||
// Shift routes the wheel to local scrollback (_shouldForwardWheelToApp returns
|
||||
// false on Shift). So under Shift, read whichever axis dominates — otherwise
|
||||
// deltaY≈0 collapses to a fixed ±1 line/tick and the gesture can't page through
|
||||
// history on a trackpad (issue #154). Non-Shift and mouse-wheel paths are
|
||||
// unchanged (they carry deltaY). The `|| ±1` keeps sub-25px deltas moving.
|
||||
_wheelScrollLines(ev) {
|
||||
const delta = ev.shiftKey && Math.abs(ev.deltaX) > Math.abs(ev.deltaY) ? ev.deltaX : ev.deltaY;
|
||||
return Math.round(delta / 25) || (delta > 0 ? 1 : -1);
|
||||
},
|
||||
|
||||
_shouldForwardWheelToApp(ev) {
|
||||
if (ev.shiftKey) return false;
|
||||
// Opt-out (App Settings → Input → "Wheel scrolls local history"): pin the
|
||||
// plain wheel to xterm's own scrollback like pre-#144, for users who prefer
|
||||
// it over forwarding the wheel to the CLI's transcript (issue #154). Cheap —
|
||||
// loadAppSettingsFromStorage() is cache-backed.
|
||||
if (this.loadAppSettingsFromStorage?.()?.terminalWheelLocalScrollback) return false;
|
||||
const mode = this.terminal?.modes?.mouseTrackingMode;
|
||||
if (mode && mode !== 'none') return false;
|
||||
const session = this.sessions?.get(this.activeSessionId);
|
||||
@@ -2535,12 +2552,13 @@ Object.assign(CodemanApp.prototype, {
|
||||
},
|
||||
|
||||
// Encode wheel ticks as SGR reports (button 64 = up, 65 = down) at the pointer
|
||||
// cell. Reports are coalesced into one PTY write per ~40ms: a trackpad emits
|
||||
// dozens of wheel events per second and each _sendInputAsync becomes a tmux
|
||||
// send-keys on the server — unbatched, a single flick would spawn a process
|
||||
// storm. Per-event tick count is capped (Claude applies its own scroll-speed
|
||||
// multiplier and acceleration on top), and the queue is bounded so a wild
|
||||
// scroll can't build a backlog that keeps scrolling after the finger stops.
|
||||
// cell. Reports are coalesced into one fire-and-forget write per ~40ms: a
|
||||
// trackpad emits dozens of wheel events per second and each send becomes a
|
||||
// tmux send-keys on the server — unbatched, a single flick would spawn a
|
||||
// process storm. Per-event tick count is capped (Claude applies its own
|
||||
// scroll-speed multiplier and acceleration on top), and the queue is bounded
|
||||
// so a wild scroll can't build a backlog that keeps scrolling after the finger
|
||||
// stops. Flushed via _sendInputEphemeral — loss-tolerant, off the durable queue.
|
||||
_sendSyntheticSgrWheel(clientX, clientY, lines) {
|
||||
if (!this.activeSessionId || !lines) return;
|
||||
const pos = this._clientPointToCell(clientX, clientY);
|
||||
@@ -2558,7 +2576,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
this._wheelSgrFlushTimer = null;
|
||||
const data = this._wheelSgrQueue;
|
||||
this._wheelSgrQueue = '';
|
||||
if (data && this.activeSessionId) this._sendInputAsync(this.activeSessionId, data);
|
||||
// Ephemeral (fire-and-forget): wheel reports are loss-tolerant, so they skip
|
||||
// the durable seq/ACK queue — no localStorage churn, no "Nb queued" flicker
|
||||
// in the connection indicator on every scroll tick.
|
||||
if (data && this.activeSessionId) this._sendInputEphemeral(this.activeSessionId, data);
|
||||
},
|
||||
|
||||
// Desktop counterpart of the touchend tap branch: hand-encode an SGR report
|
||||
|
||||
@@ -370,12 +370,54 @@ describe('terminal touch tap mouse guard', () => {
|
||||
expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(false);
|
||||
});
|
||||
|
||||
it('wheel: the local-scrollback opt-out pins the plain wheel to local scrollback (issue #154)', () => {
|
||||
const { app } = loadTerminalUiHarness();
|
||||
app.activeSessionId = 'sess-1';
|
||||
app.sessions = new Map([['sess-1', { mode: 'claude', cliVersion: '2.1.187' }]]);
|
||||
app.terminal = {
|
||||
modes: { mouseTrackingMode: 'none' },
|
||||
buffer: { active: { viewportY: 50, baseY: 50 } },
|
||||
};
|
||||
|
||||
// Default (setting absent) forwards the plain wheel to the CLI transcript.
|
||||
expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(true);
|
||||
|
||||
// Opt-out ON → the plain wheel stays on xterm's own scrollback (pre-#144).
|
||||
app.loadAppSettingsFromStorage = () => ({ terminalWheelLocalScrollback: true });
|
||||
expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(false);
|
||||
|
||||
// OFF again → forwarding resumes.
|
||||
app.loadAppSettingsFromStorage = () => ({ terminalWheelLocalScrollback: false });
|
||||
expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(true);
|
||||
});
|
||||
|
||||
it('wheel: reads the dominant axis under Shift so a macOS trackpad can page scrollback (issue #154)', () => {
|
||||
const { app } = loadTerminalUiHarness();
|
||||
|
||||
// Plain vertical wheel: unchanged, driven by deltaY.
|
||||
expect(app._wheelScrollLines({ shiftKey: false, deltaX: 0, deltaY: 100 })).toBe(4);
|
||||
expect(app._wheelScrollLines({ shiftKey: false, deltaX: 0, deltaY: -50 })).toBe(-2);
|
||||
|
||||
// Shift on a macOS trackpad: deltaY≈0, deltaX carries direction+magnitude.
|
||||
// Old code collapsed this to a fixed -1; now it tracks the horizontal delta.
|
||||
expect(app._wheelScrollLines({ shiftKey: true, deltaX: -100, deltaY: 0 })).toBe(-4); // scroll up
|
||||
expect(app._wheelScrollLines({ shiftKey: true, deltaX: 75, deltaY: 0 })).toBe(3); // scroll down
|
||||
|
||||
// Shift with a real vertical wheel (mouse): deltaY dominates, deltaX ignored.
|
||||
expect(app._wheelScrollLines({ shiftKey: true, deltaX: 2, deltaY: 100 })).toBe(4);
|
||||
|
||||
// Sub-25px delta still nudges one line in the gesture's direction.
|
||||
expect(app._wheelScrollLines({ shiftKey: true, deltaX: -5, deltaY: 0 })).toBe(-1);
|
||||
});
|
||||
|
||||
it('wheel: encodes SGR 64/65 ticks, caps per event, and coalesces into one flush', () => {
|
||||
const { app } = loadTerminalUiHarness();
|
||||
const sent: Array<{ id: string; data: string }> = [];
|
||||
app.activeSessionId = 'sess-1';
|
||||
app.sessions = new Map([['sess-1', { mode: 'claude' }]]);
|
||||
app._sendInputAsync = (id: string, data: string) => sent.push({ id, data });
|
||||
// Wheel reports flush via the ephemeral (fire-and-forget) path, not the
|
||||
// durable queue — so they never show in the pending-bytes indicator (#154).
|
||||
app._sendInputEphemeral = (id: string, data: string) => sent.push({ id, data });
|
||||
app.terminal = {
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
|
||||
Reference in New Issue
Block a user