mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 20:49:41 +02:00
Compare commits
22
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bdaec320f5 | ||
|
|
dfe20a3742 | ||
|
|
de5216b83f | ||
|
|
57eefd7aa5 | ||
|
|
2c81bbc08b | ||
|
|
b374121c18 | ||
|
|
b1c4330680 | ||
|
|
47359e4002 | ||
|
|
a8e7d60db4 | ||
|
|
8dc70a5f1d | ||
|
|
4d129086d1 | ||
|
|
566c65c3c9 | ||
|
|
cd7d8c7329 | ||
|
|
c55af9ec39 | ||
|
|
1a54217bfb | ||
|
|
70742d400a | ||
|
|
d5809d1808 | ||
|
|
a0ac10a07c | ||
|
|
f7814ad364 | ||
|
|
3172befd5d | ||
|
|
29ffc62536 | ||
|
|
4cb3a4aac8 |
@@ -1,5 +1,71 @@
|
||||
# aicodeman
|
||||
|
||||
## 1.1.10
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Mobile CJK input, iPad keyboard accessory bar, and terminal touch interaction fixes (PRs #130, #131).
|
||||
|
||||
Mobile / CJK (#130):
|
||||
- Restore reliable real-time CJK (e.g. Pinyin) composition in the always-visible textarea, and refocus input when the terminal is tapped.
|
||||
- Stop clearing the textarea during `compositionstart` — some IMEs include existing text in the composition region, and clearing it mid-composition corrupted input.
|
||||
- iPad-specific fixes: `#cjkInput` positioning, paste-dialog placement, and duplicated voice-dictation output.
|
||||
- Split CJK keyboard positioning by device size (phones vs iPad use different keyboard offsets).
|
||||
- iPad accessory-bar styling/positioning: moved the accessory-bar and paste-overlay base styles out of the `max-width:1023px`-gated mobile stylesheet so iPad landscape (≥1024px) renders them correctly.
|
||||
- Raise the toolbar stacking context while the case-settings popover is open so the popover is no longer hidden behind the toolbar.
|
||||
- Restore the `/compact` button to the keyboard accessory bar (with double-tap confirmation, like `/clear`); the paste dialog now submits pasted text on "Send".
|
||||
|
||||
Terminal touch + forced redraw (#131):
|
||||
- Enable terminal touch interaction on all touch devices and show the stop button on touch devices.
|
||||
- Add an 8px tap threshold so micro-drift is treated as a tap, not a scroll, fixing cases where a tap failed to register.
|
||||
- Tap-to-position the cursor via a synthesized mouse report, gated on the live mouse-tracking mode so it never triggers local text selection when tracking is off; let SGR mouse reports through to the PTY even while the CJK input field owns focus.
|
||||
- Suppress the cursor/momentum side effects of a sub-threshold tap so a jittery tap no longer both positions the cursor and starts a momentum fling.
|
||||
- New opt-in, per-device "Redraw Terminal" header button (`showRedrawButton`, default off) that forces an xterm redraw via a resize jitter to clear occasional rendering glitches; the resize path now accepts a `force` flag (threaded through the session, HTTP, and WebSocket resize routes) that guarantees a SIGWINCH/redraw at the current device's size without bypassing multi-client resize arbitration.
|
||||
|
||||
## 1.1.9
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Two welcome-screen tunnel changes:
|
||||
- **UI (Daylight Blue skin):** the **Cloudflare Tunnel** button is now purple (was orange/yellow), keeping the three welcome buttons visually distinct — Claude blue, Tunnel purple, OpenCode green.
|
||||
- **Enable a tunnel without `CODEMAN_PASSWORD`, with a warning.** Previously enabling the Cloudflare tunnel with no password set was hard-refused unless you set `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1`. Now you can opt in straight from the browser: clicking the tunnel toggle without a password pops a **security confirm dialog** ("publishes this machine to a public URL with no login — effectively remote code execution; set CODEMAN_PASSWORD instead"), and only on confirm does it enable, sending an explicit per-request `acknowledgeUnauthTunnel:true`. The server logs a loud warning whenever a passwordless public tunnel starts. curl/API/CLI callers are unchanged — still refused unless they set a password, set the env var, or pass `acknowledgeUnauthTunnel:true` — so nothing gets exposed accidentally. The acknowledgment is an action field and is never persisted to settings.json.
|
||||
|
||||
## 1.1.8
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- UI (Daylight Blue skin): give the welcome-screen action buttons distinct colors instead of all reading blue. **Run Claude Code** keeps the blue accent, **Cloudflare Tunnel** now uses Cloudflare's brand orange, and **Run OpenCode** uses an emerald green — so the three are visually distinguishable at a glance. Scoped to the default `daylight-blue` skin only (daylight-green and OG are unchanged), with matching hover/active states and dark ink for contrast. Verified in a real browser: the three buttons compute to blue / orange / green gradients on the welcome overlay.
|
||||
|
||||
## 1.1.7
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Fix: terminal scroll-up (scrollback) intermittently breaking for **Claude** sessions — most visible on iPhone, where you suddenly "can't scroll up the Claude console."
|
||||
|
||||
Root cause: Claude Code periodically emits alternate-screen switches (`\x1b[?1049h`/`\x1b[?47h`/`\x1b[?1047h`), scrollback-erase (`\x1b[3J`), and mouse-tracking enables — typically when it draws a full-screen UI (pickers/dialogs, the boot welcome). xterm.js obeys these by moving to the scrollback-less alternate buffer (or wiping saved lines / hijacking the wheel), so the conversation history becomes unreachable until Claude returns to its normal view. Codeman already stripped these sequences so history stays scrollable, but the strip was gated to **Codex mode only** — Claude (and the equivalent buffer-replay path) let them through.
|
||||
|
||||
The strip is now shared via a single `isAltScreenStripMode(mode)` predicate (`codex || claude`) applied at BOTH sites that were Codex-only: the live PTY stream (`Session._handleTerminalOutput`, including the split-across-chunks carry reassembly) and the `/terminal` buffer replay used on tab-switch/reconnect. `shell` is deliberately excluded so full-screen TUIs run from a shell (vim/less/htop) keep their alternate screen; `opencode` is also unchanged.
|
||||
|
||||
Verified end-to-end on an isolated instance against a real Claude session: the replayed buffer and live stream now carry zero alt-screen/scrollback-erase/mouse sequences, the terminal stays in the normal buffer with scrollback intact, and touch swipe-up scrolls correctly. Covered by new unit tests (`test/claude-scrollback-strip.test.ts`); the existing Codex strip tests are unchanged.
|
||||
|
||||
## 1.1.6
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Fix: ultracode floating run windows now pop on a fresh device/browser that loads while a run is already active.
|
||||
|
||||
`ultracodeFloatingWindows` syncs from the server (it's a non-display setting), but on a first-time device the SSE `getLightState` run snapshot can seed the run list BEFORE the async settings load resolves — so the floating-window gate read `false` at that instant and skipped any already-active run, leaving the window un-popped until the next ~10s watcher tick. The app now re-runs `syncAllUltracodeFloatingWindows()` once server settings finish loading (in the `loadAppSettingsFromServer().then()` callback), so an in-flight run pops its window immediately. Idempotent: open windows are left as-is, and if the setting is off any premature windows are torn down. Verified end-to-end against a real in-flight run on an isolated instance — a pristine browser (empty localStorage) seeds the setting from the server and pops the active run's window ~0.4s after first paint.
|
||||
|
||||
Also corrected a stale `@fileoverview` comment in `ultracode-windows.js` that claimed the floating windows are gated on `showUltracodeAgents`; they are gated on the dedicated `ultracodeFloatingWindows` toggle (only the docked "Ultracode Agents" panel uses `showUltracodeAgents`).
|
||||
|
||||
## 1.1.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Fix: the Ultracode Agents panel's (×) Close button now fully hides the panel.
|
||||
|
||||
`closeUltracodeAgentsPanel()` only removed the `open` class, which drops the bottom-docked drawer to its collapsed _peek_ state (the 36px header strip stays visible) rather than closing it — so clicking (×) looked like it did nothing. It now also adds the `hidden` class (`display:none`), mirroring `closeSubagentsPanel()`. It deliberately does NOT flip the `showUltracodeAgents` setting (that also gates the run watcher and floating windows); the header launcher button reopens the panel. Verified in a real browser: after (×) the panel computes `display:none`.
|
||||
|
||||
## 1.1.4
|
||||
|
||||
### 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.1.4 (must match `package.json`)
|
||||
**Version**: 1.1.10 (must match `package.json`)
|
||||
|
||||
## Project Overview
|
||||
|
||||
@@ -132,7 +132,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
| **Attachments** | `src/attachment-registry.ts`, `src/attachment-magic.ts`, `src/session-attachment-history.ts`, `src/document-preview-cache.ts`, `src/document-thumbnailer.ts`, `src/document-conversion-limiter.ts`, `src/config/attachment-guard.ts` | See Key Patterns |
|
||||
| **Plan** | `src/plan-orchestrator.ts`, `src/prompts/*.ts`, `src/templates/` (`claude-md.ts` + `case-template.md`, the CLAUDE.md scaffold generated into new cases) | |
|
||||
| **Web** | `src/web/server.ts` ★, `src/web/sse-events.ts`, `src/web/routes/*.ts` (16 route modules + barrel; `session-routes.ts` ★), `src/web/route-helpers.ts`, `src/web/ports/*.ts`, `src/web/middleware/auth.ts`, `src/web/schemas.ts`, `src/web/self-update.ts`, `src/web/plan-usage-latest.ts` | |
|
||||
| **Frontend** | `src/web/public/app.js` (~3.9K lines, core) + 6 infra modules (`constants.js`, `mobile-handlers.js`, `voice-input.js`, `notification-manager.js`, `keyboard-accessory.js`, `sanitize-html.js` — DOMPurify mXSS allowlist, COD-56) + 8 domain modules (`terminal-ui.js`, `respawn-ui.js`, `ralph-panel.js`, `orchestrator-panel.js`, `ultracode-panel.js`, `settings-ui.js`, `panels-ui.js`, `session-ui.js`) + 6 feature modules (`ralph-wizard.js`, `api-client.js`, `subagent-windows.js`, `ultracode-windows.js`, `input-cjk.js`, `image-input.js`) + `sw.js` | `ultracode-windows.js` = floating run windows w/ tab connector lines (additional to the dock panel) |
|
||||
| **Frontend** | `src/web/public/app.js` (~4K lines, core) + 6 infra modules (`constants.js`, `mobile-handlers.js`, `voice-input.js`, `notification-manager.js`, `keyboard-accessory.js`, `sanitize-html.js` — DOMPurify mXSS allowlist, COD-56) + 8 domain modules (`terminal-ui.js`, `respawn-ui.js`, `ralph-panel.js`, `orchestrator-panel.js`, `ultracode-panel.js`, `settings-ui.js`, `panels-ui.js`, `session-ui.js`) + 6 feature modules (`ralph-wizard.js`, `api-client.js`, `subagent-windows.js`, `ultracode-windows.js`, `input-cjk.js`, `image-input.js`) + `sw.js` | `ultracode-windows.js` = floating run windows w/ tab connector lines (additional to the dock panel) |
|
||||
| **Types** | `src/types/index.ts` (barrel) → 16 domain files (incl. `workflow-run.ts`); also `src/types.ts` root re-export | See `@fileoverview` in index.ts |
|
||||
|
||||
★ = Large, central file (>50KB) — read its `@fileoverview` first. All files have `@fileoverview` JSDoc — read that before diving in. Discovery aid: `grep -l '@fileoverview' src/web/routes/*.ts` lists all route modules; same grep works for `src/types/`, `src/web/public/*.js`.
|
||||
@@ -211,7 +211,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
|
||||
| **QR Auth** | Single-use 6-char tokens (60s TTL) for tunnel login. See `docs/qr-auth-plan.md` |
|
||||
| **Sessions** | 24h cookie (`codeman_session`), auto-extend, device context audit |
|
||||
| **Rate limit** | 10 failed auth/IP → 429 (15min decay). QR has separate limiter |
|
||||
| **Hook bypass** | `/api/hook-event` (and `/api/status-telemetry`, the statusLine exporter) exempt from auth (localhost-only, schema-validated). While the **managed tunnel** runs, the bypass additionally requires the per-instance `X-Codeman-Hook-Secret` header (COD-54, `config/hook-secret.ts`): hook curls cat the secret file at exec time via `$CODEMAN_HOOK_SECRET_FILE` (session env), failures rate-limit in a dedicated bucket (never lock out login). External loopback proxies (own cloudflared/`tailscale serve`) aren't detected — plain bypass still applies there. Tunnel enable also **refuses** without `CODEMAN_PASSWORD` unless `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1` (COD-55) |
|
||||
| **Hook bypass** | `/api/hook-event` (and `/api/status-telemetry`, the statusLine exporter) exempt from auth (localhost-only, schema-validated). While the **managed tunnel** runs, the bypass additionally requires the per-instance `X-Codeman-Hook-Secret` header (COD-54, `config/hook-secret.ts`): hook curls cat the secret file at exec time via `$CODEMAN_HOOK_SECRET_FILE` (session env), failures rate-limit in a dedicated bucket (never lock out login). External loopback proxies (own cloudflared/`tailscale serve`) aren't detected — plain bypass still applies there. Tunnel enable **refuses** without `CODEMAN_PASSWORD` unless exposure is acknowledged — via `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1` (env, COD-55) **or** the per-request `acknowledgeUnauthTunnel:true` action field (1.1.9): the welcome/settings tunnel toggle pops a security confirm dialog and, on confirm, resends with that flag (server logs a loud warning on every passwordless tunnel start; curl/API stay refused without password/env/flag). The flag is an action field, never persisted |
|
||||
| **Env vars** | `CODEMAN_MUX` (managed session), `CODEMAN_API_URL` (auto-set for hooks), `CODEMAN_ALLOWED_HOSTS` (extra Host/Origin allowlist entries for reverse proxies, comma-separated; bare `.suffix` matches subdomains) |
|
||||
| **Validation** | Zod schemas, path allowlist regex, env prefix allowlist (`CLAUDE_CODE_*`/`OPENCODE_*`/`CODEX_*`) |
|
||||
| **Headers** | CORS localhost-only, CSP, X-Frame-Options, HSTS if HTTPS |
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "1.1.4",
|
||||
"version": "1.1.10",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "aicodeman",
|
||||
"version": "1.1.4",
|
||||
"version": "1.1.10",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "1.1.4",
|
||||
"version": "1.1.10",
|
||||
"description": "Mission control for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
|
||||
+37
-15
@@ -136,6 +136,21 @@ export function isExternalCliMode(mode: SessionMode): boolean {
|
||||
return mode === 'opencode' || mode === 'codex';
|
||||
}
|
||||
|
||||
/**
|
||||
* Modes whose TUI emits alt-screen / scrollback-erase / mouse-tracking sequences
|
||||
* that we strip so the browser keeps everything in the main buffer with scrollback
|
||||
* reachable (the strip runs on both the live stream and the buffer replay).
|
||||
*
|
||||
* Codex and Claude Code are known, controlled TUIs that repaint via cursor
|
||||
* positioning, so dropping the alt-screen switch is safe — content stays in the
|
||||
* normal buffer. Excluded: `shell` (arbitrary programs like vim/less/htop
|
||||
* legitimately need the alt screen) and `opencode` (renders its own TUI that
|
||||
* may rely on it). Keep parity with the replay-side strip in session-routes.ts.
|
||||
*/
|
||||
export function isAltScreenStripMode(mode: SessionMode): boolean {
|
||||
return mode === 'codex' || mode === 'claude';
|
||||
}
|
||||
|
||||
// Note: Claude CLI PATH resolution moved to session-cli-builder.ts (buildClaudeEnv)
|
||||
|
||||
/** PTY fallback geometry when tmux can't be queried (matches pre-#80 hardcoded values). */
|
||||
@@ -265,9 +280,10 @@ export class Session extends EventEmitter {
|
||||
private _messages: ClaudeMessage[] = [];
|
||||
private _lineBuffer: string = '';
|
||||
private _lineBufferFlushTimer: NodeJS.Timeout | null = null;
|
||||
// Codex only: trailing partial CSI held back so sequences split across PTY
|
||||
// chunks can't slip past the alt-screen/scrollback strip (see _handleTerminalOutput)
|
||||
private _codexSeqCarry: string = '';
|
||||
// Alt-screen-strip modes (Codex/Claude): trailing partial CSI held back so
|
||||
// sequences split across PTY chunks can't slip past the alt-screen/scrollback
|
||||
// strip (see _handleTerminalOutput / isAltScreenStripMode)
|
||||
private _altScreenSeqCarry: string = '';
|
||||
private resolvePromise: ((value: { result: string; cost: number }) => void) | null = null;
|
||||
private rejectPromise: ((reason: Error) => void) | null = null;
|
||||
private _promptResolved: boolean = false; // Guard against race conditions in runPrompt
|
||||
@@ -1134,35 +1150,40 @@ export class Session extends EventEmitter {
|
||||
}
|
||||
|
||||
private _handleTerminalOutput(data: string): void {
|
||||
// Codex emits sequences that wipe xterm.js scrollback, plus mouse-tracking
|
||||
// enables that hijack the scroll wheel so the user can't reach scrollback:
|
||||
// Codex AND Claude Code emit sequences that wipe xterm.js scrollback, plus
|
||||
// mouse-tracking enables that hijack the scroll wheel so the user can't reach
|
||||
// scrollback. Claude Code does this intermittently (e.g. full-screen pickers /
|
||||
// dialogs), which is why terminal scroll-up "randomly" breaks for Claude
|
||||
// sessions on mobile and desktop until the dialog closes:
|
||||
// - \x1b[?1049h / \x1b[?47h / \x1b[?1047h: switch to the alt buffer (no
|
||||
// scrollback) — \x1b[?...l switches back.
|
||||
// - \x1b[3J: erase saved lines (scrollback). (\x1b[2J / \x1b[J — erase
|
||||
// the visible viewport — are left intact; the TUI repaints those rows.)
|
||||
// - \x1b[?1000h / 1002h / 1003h / 1005h / 1006h / 1007h: mouse-tracking
|
||||
// modes (X10, button-event, any-event, UTF-8, SGR, alt-scroll). Once on,
|
||||
// xterm.js forwards wheel events to codex instead of scrolling the
|
||||
// xterm.js forwards wheel events to the CLI instead of scrolling the
|
||||
// viewport, so the conversation is in scrollback but unreachable.
|
||||
// (Focus events at ?1004 are left alone — codeman uses them for
|
||||
// active-tab detection.)
|
||||
// Strip them at the source so neither the persisted buffer nor the live
|
||||
// SSE/WS stream carries them, keeping everything in the main buffer with
|
||||
// scrollback intact. Codex's cursor-positioned redraws overwrite only the
|
||||
// cells they actually target, so the non-erased rows keep their content.
|
||||
if (this.mode === 'codex') {
|
||||
// scrollback intact. These are controlled TUIs whose cursor-positioned
|
||||
// redraws overwrite only the cells they target, so non-erased rows keep
|
||||
// their content. Gated to Codex/Claude (isAltScreenStripMode) — shell must
|
||||
// keep the alt screen for vim/less/htop.
|
||||
if (isAltScreenStripMode(this.mode)) {
|
||||
// Reassemble sequences split across PTY chunk boundaries first: a chunk
|
||||
// ending mid-sequence ('\x1b[?104' now, '9h' next) would slip past the
|
||||
// strip below and leave xterm stuck in the scrollback-less alt buffer
|
||||
// until the next buffer replay. Hold back an incomplete digit-only CSI
|
||||
// tail (≤7 chars — the longest strippable intro is '\x1b[?1049') and
|
||||
// prepend it to the next chunk; complete sequences are never held.
|
||||
data = this._codexSeqCarry + data;
|
||||
this._codexSeqCarry = '';
|
||||
data = this._altScreenSeqCarry + data;
|
||||
this._altScreenSeqCarry = '';
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const splitTail = data.match(/\x1b(?:\[\??[0-9]{0,4})?$/);
|
||||
if (splitTail) {
|
||||
this._codexSeqCarry = splitTail[0];
|
||||
this._altScreenSeqCarry = splitTail[0];
|
||||
data = data.slice(0, -splitTail[0].length);
|
||||
if (!data) return;
|
||||
}
|
||||
@@ -1809,7 +1830,7 @@ export class Session extends EventEmitter {
|
||||
this._errorBuffer = '';
|
||||
this._messages = [];
|
||||
this._lineBuffer = '';
|
||||
this._codexSeqCarry = '';
|
||||
this._altScreenSeqCarry = '';
|
||||
this._lastActivityAt = Date.now();
|
||||
}
|
||||
|
||||
@@ -2300,7 +2321,7 @@ export class Session extends EventEmitter {
|
||||
* @param cols - Number of columns (width in characters)
|
||||
* @param rows - Number of rows (height in lines)
|
||||
*/
|
||||
resize(cols: number, rows: number, options: { viewportType?: ResizeViewportType } = {}): void {
|
||||
resize(cols: number, rows: number, options: { viewportType?: ResizeViewportType; force?: boolean } = {}): void {
|
||||
const isSmallViewport = options.viewportType === 'mobile' || options.viewportType === 'tablet';
|
||||
if (options.viewportType === 'desktop') {
|
||||
this._lastDesktopDims = { cols, rows };
|
||||
@@ -2313,7 +2334,8 @@ export class Session extends EventEmitter {
|
||||
}
|
||||
this._mobileSizeOverride = true;
|
||||
}
|
||||
if (this.ptyProcess && (cols !== this._ptyCols || rows !== this._ptyRows)) {
|
||||
const dimsChanged = cols !== this._ptyCols || rows !== this._ptyRows;
|
||||
if (this.ptyProcess && (dimsChanged || options.force)) {
|
||||
this._ptyCols = cols;
|
||||
this._ptyRows = rows;
|
||||
if (this._mux && this._muxSession) {
|
||||
|
||||
+10
-1
@@ -665,6 +665,16 @@ class CodemanApp {
|
||||
this.applySkin();
|
||||
this.applyTabWrapSettings();
|
||||
this.applyMonitorVisibility();
|
||||
// ultracodeFloatingWindows syncs from the server (non-display key), but on a
|
||||
// FRESH device the getLightState run snapshot can seed workflowRuns BEFORE this
|
||||
// async settings load resolves — so the floating-window gate read false then and
|
||||
// skipped any already-active run. Re-sync now that the real setting is loaded so
|
||||
// an in-flight run pops its window immediately instead of waiting for the next
|
||||
// ~10s SSE tick. Idempotent: open windows are left as-is; if the setting is off
|
||||
// it tears any premature windows down.
|
||||
if (typeof this.syncAllUltracodeFloatingWindows === 'function') {
|
||||
this.syncAllUltracodeFloatingWindows();
|
||||
}
|
||||
});
|
||||
// Hide loading skeleton now that the app shell is ready
|
||||
document.body.classList.add('app-loaded');
|
||||
@@ -2196,7 +2206,6 @@ class CodemanApp {
|
||||
document.body.classList.toggle('cjk-input-visible', !!showCjk);
|
||||
cjkEl.style.display = showCjk ? 'block' : 'none';
|
||||
cjkEl.setAttribute('aria-hidden', showCjk ? 'false' : 'true');
|
||||
if (showCjk && cjkEl.value === '\u200B') cjkEl.value = '';
|
||||
if (!showCjk) window.cjkActive = false;
|
||||
if (typeof KeyboardHandler !== 'undefined') KeyboardHandler.updateLayoutForKeyboard();
|
||||
}
|
||||
|
||||
@@ -116,6 +116,7 @@
|
||||
<span class="stat-value" id="statMem">--</span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn-icon-header btn-redraw-terminal btn-redraw-terminal--hidden" onclick="app.restoreTerminalSize()" title="Redraw terminal to fit current screen (Ctrl+Shift+R)" aria-label="Redraw terminal"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="1 4 1 10 7 10"/><polyline points="23 20 23 14 17 14"/><path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 0 1 3.51 15"/></svg></button>
|
||||
<button class="btn-icon-header btn-response-viewer-header btn-response-viewer-header--hidden" onclick="app.toggleResponseViewer()" title="View last response" aria-label="View last response"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg></button>
|
||||
<button class="btn-icon-header btn-attachments-history btn-attachments-history--hidden" id="attachmentsHistoryBtn" onclick="app.toggleAttachmentHistory()" title="Attachments" aria-label="Open attachment history" aria-expanded="false">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
|
||||
@@ -1058,6 +1059,13 @@
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="settings-item" title="Show a terminal redraw button in the header — refit the terminal to the current screen size (useful when switching between devices)">
|
||||
<span class="settings-item-label">Redraw Terminal Button</span>
|
||||
<label class="switch switch-sm">
|
||||
<input type="checkbox" id="appSettingsShowRedrawButton">
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Tab Bar Section -->
|
||||
<div class="settings-section-header">Tab Bar</div>
|
||||
|
||||
+123
-123
@@ -14,13 +14,21 @@
|
||||
* 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, all input events are ignored — only compositionend
|
||||
* triggers a flush (CJK candidate selection).
|
||||
*
|
||||
* During composition, `insertText` events are flushed immediately (punctuation,
|
||||
* English words confirmed by IME). `insertCompositionText` waits for
|
||||
* compositionend (CJK candidate selection).
|
||||
* ## iOS dictation challenge (WebKit Bug 261764)
|
||||
*
|
||||
* iOS/iPadOS voice dictation does NOT fire composition events. Text arrives
|
||||
* as bare input events with isComposing === false. Dictation refinement is
|
||||
* a delete→reinsert cycle (deleteContentBackward + insertReplacementText),
|
||||
* all within a few ms. Flushing on every input event would send irrevocable
|
||||
* provisional text to the PTY, causing duplication when the IME replaces it.
|
||||
*
|
||||
* Solution: outside composition, flush is DEBOUNCED (200ms). The entire
|
||||
* delete→reinsert cycle collapses into one flush of the final textarea value.
|
||||
* Keyboard typing of single printable characters still goes through the
|
||||
* keydown handler (immediate, no debounce).
|
||||
*
|
||||
* ## Phantom character for Android backspace
|
||||
*
|
||||
@@ -41,16 +49,30 @@
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
const CjkInput = (() => {
|
||||
let _textarea = null;
|
||||
let _terminalContainer = null;
|
||||
let _xtermTextarea = null;
|
||||
let _send = null;
|
||||
let _initialized = false;
|
||||
let _composing = false;
|
||||
let _flushTimer = null;
|
||||
let _dictationActive = false;
|
||||
let _dictationDecayTimer = null;
|
||||
let _keydownSentAt = 0;
|
||||
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 PHANTOM = '';
|
||||
|
||||
// Two-tier debounce for non-composition input:
|
||||
// - KEYBOARD: short debounce (third-party IMEs like Doubao may not fire
|
||||
// composition events even for keyboard CJK typing)
|
||||
// - DICTATION: long debounce (iOS voice dictation sends delete→reinsert
|
||||
// refinement cycles without composition events — WebKit Bug 261764)
|
||||
//
|
||||
// Dictation is detected by deleteContentBackward on non-empty text or
|
||||
// insertReplacementText — signals that the IME is rewriting provisional
|
||||
// text. Once detected, dictation mode persists for 3s (covers multi-word
|
||||
// dictation with natural pauses between words).
|
||||
const DEBOUNCE_KEYBOARD_MS = 150;
|
||||
const DEBOUNCE_DICTATION_MS = 1500;
|
||||
const DICTATION_DECAY_MS = 3000;
|
||||
|
||||
const PASSTHROUGH_KEYS = {
|
||||
ArrowUp: '\x1b[A',
|
||||
@@ -66,35 +88,15 @@ 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, '');
|
||||
return str.replace(//g, '');
|
||||
}
|
||||
|
||||
/** Reset textarea to phantom-only state with cursor at end */
|
||||
function _resetToPhantom() {
|
||||
_textarea.value = PHANTOM;
|
||||
_textarea.setSelectionRange(1, 1);
|
||||
}
|
||||
|
||||
function _isMobileComposer() {
|
||||
return !!(
|
||||
_textarea &&
|
||||
typeof MobileDetection !== 'undefined' &&
|
||||
MobileDetection.isTouchDevice() &&
|
||||
_textarea.classList.contains('cjk-input-visible')
|
||||
);
|
||||
}
|
||||
|
||||
function _resetInput() {
|
||||
if (_isMobileComposer()) {
|
||||
_textarea.value = '';
|
||||
} else {
|
||||
_resetToPhantom();
|
||||
}
|
||||
}
|
||||
|
||||
/** Check if textarea contains only phantom(s) or is empty — no real user text */
|
||||
function _isEffectivelyEmpty() {
|
||||
return !_strip(_textarea.value);
|
||||
}
|
||||
@@ -108,61 +110,77 @@ const CjkInput = (() => {
|
||||
_resetToPhantom();
|
||||
}
|
||||
|
||||
/** Cancel any pending debounced flush */
|
||||
function _cancelDebouncedFlush() {
|
||||
if (_flushTimer) {
|
||||
clearTimeout(_flushTimer);
|
||||
_flushTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Mark that dictation rewriting is in progress */
|
||||
function _enterDictationMode() {
|
||||
_dictationActive = true;
|
||||
clearTimeout(_dictationDecayTimer);
|
||||
_dictationDecayTimer = setTimeout(() => {
|
||||
_dictationActive = false;
|
||||
_dictationDecayTimer = null;
|
||||
}, DICTATION_DECAY_MS);
|
||||
}
|
||||
|
||||
/** Schedule a flush after input settles */
|
||||
function _debouncedFlush() {
|
||||
_cancelDebouncedFlush();
|
||||
const delay = _dictationActive ? DEBOUNCE_DICTATION_MS : DEBOUNCE_KEYBOARD_MS;
|
||||
_flushTimer = setTimeout(() => {
|
||||
_flushTimer = null;
|
||||
_flush();
|
||||
}, delay);
|
||||
}
|
||||
|
||||
return {
|
||||
init({ send }) {
|
||||
if (_initialized) this.destroy();
|
||||
|
||||
_send = send;
|
||||
_composing = false;
|
||||
_flushTimer = null;
|
||||
_textarea = document.getElementById('cjkInput');
|
||||
if (!_textarea) return this;
|
||||
_terminalContainer = document.getElementById('terminalContainer');
|
||||
|
||||
// Seed the phantom character for the hidden/immediate CJK path.
|
||||
_resetInput();
|
||||
_resetToPhantom();
|
||||
|
||||
_listeners.mousedown = (e) => { e.stopPropagation(); };
|
||||
_listeners.focus = () => {
|
||||
window.cjkActive = true;
|
||||
if (_isMobileComposer() && _textarea.value === PHANTOM) {
|
||||
_textarea.value = '';
|
||||
return;
|
||||
if (!_textarea.value) _resetToPhantom();
|
||||
};
|
||||
_listeners.blur = () => {
|
||||
// Keep cjkActive while CJK input is visible — iOS dictation and system
|
||||
// UI may steal focus temporarily, and clearing the flag during that
|
||||
// window lets xterm's onData process duplicated input.
|
||||
if (!_textarea.classList.contains('cjk-input-visible')) {
|
||||
window.cjkActive = false;
|
||||
}
|
||||
// Restore phantom if textarea was emptied while blurred
|
||||
if (!_textarea.value && !_isMobileComposer()) _resetToPhantom();
|
||||
// Reset composing state — some IMEs fire compositionstart without a
|
||||
// matching compositionend, leaving _composing stuck true and blocking
|
||||
// all subsequent input events.
|
||||
_composing = false;
|
||||
};
|
||||
_listeners.blur = () => { window.cjkActive = false; };
|
||||
_textarea.addEventListener('mousedown', _listeners.mousedown);
|
||||
_textarea.addEventListener('focus', _listeners.focus);
|
||||
_textarea.addEventListener('blur', _listeners.blur);
|
||||
|
||||
_listeners.xtermFocusRedirect = () => {
|
||||
if (!_isMobileComposer()) return;
|
||||
_textarea.focus();
|
||||
};
|
||||
if (_terminalContainer) {
|
||||
_xtermTextarea = _terminalContainer.querySelector('.xterm-helper-textarea');
|
||||
if (_xtermTextarea) {
|
||||
_xtermTextarea.addEventListener('focus', _listeners.xtermFocusRedirect, { capture: true });
|
||||
}
|
||||
}
|
||||
|
||||
// ── Composition tracking ──
|
||||
// ── Composition tracking (keyboard IME — works for CJK typing) ──
|
||||
_listeners.compositionstart = () => {
|
||||
_composing = true;
|
||||
if (_isMobileComposer()) {
|
||||
if (_textarea.value === PHANTOM) _textarea.value = '';
|
||||
return;
|
||||
}
|
||||
// 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 = '';
|
||||
}
|
||||
_cancelDebouncedFlush();
|
||||
// Leave textarea.value untouched — programmatic changes during
|
||||
// compositionstart cancel the IME composition on iOS Safari.
|
||||
};
|
||||
_listeners.compositionend = () => {
|
||||
_composing = false;
|
||||
if (_isMobileComposer()) return;
|
||||
_cancelDebouncedFlush();
|
||||
// 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);
|
||||
@@ -172,56 +190,40 @@ const CjkInput = (() => {
|
||||
|
||||
// ── 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();
|
||||
_composing = false;
|
||||
_cancelDebouncedFlush();
|
||||
const val = _strip(_textarea.value);
|
||||
if (val) {
|
||||
_send(val + '\r');
|
||||
} else {
|
||||
_send('\r');
|
||||
}
|
||||
_resetInput();
|
||||
_resetToPhantom();
|
||||
return;
|
||||
}
|
||||
|
||||
// Escape: clear textarea (always works)
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
_composing = false;
|
||||
_resetInput();
|
||||
_cancelDebouncedFlush();
|
||||
_resetToPhantom();
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl combos: forward to PTY (always works)
|
||||
if (e.ctrlKey && CTRL_KEYS[e.key]) {
|
||||
e.preventDefault();
|
||||
_send(CTRL_KEYS[e.key]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Below: only when NOT composing (composing keystrokes belong to IME)
|
||||
if (_composing) return;
|
||||
|
||||
if (_isMobileComposer()) {
|
||||
if (e.key === 'Backspace' && _isEffectivelyEmpty()) {
|
||||
e.preventDefault();
|
||||
_send('\x7f');
|
||||
return;
|
||||
}
|
||||
if (PASSTHROUGH_KEYS[e.key] && _isEffectivelyEmpty()) {
|
||||
e.preventDefault();
|
||||
_send(PASSTHROUGH_KEYS[e.key]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Below: only when NOT composing (composing keystrokes belong to IME).
|
||||
// Also check isComposing/keyCode 229 — the first keydown of a CJK
|
||||
// sequence arrives BEFORE compositionstart, so _composing is still false.
|
||||
if (_composing || e.isComposing || e.keyCode === 229) 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');
|
||||
@@ -236,62 +238,62 @@ const CjkInput = (() => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Single printable character: send immediately to PTY
|
||||
// (Desktop keyboards with physical keys — Android sends 'Unidentified')
|
||||
// Single printable character: send immediately to PTY.
|
||||
// Third-party IMEs on iOS may ignore preventDefault, so the char
|
||||
// still enters the textarea and fires an input event — _keydownSentAt
|
||||
// tells the input handler to skip that echo.
|
||||
if (e.key.length === 1 && !e.ctrlKey && !e.altKey && !e.metaKey && _isEffectivelyEmpty()) {
|
||||
e.preventDefault();
|
||||
_send(e.key);
|
||||
_keydownSentAt = performance.now();
|
||||
_resetToPhantom();
|
||||
return;
|
||||
}
|
||||
};
|
||||
_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.
|
||||
// ── Input event: primary path for virtual keyboards + dictation ──
|
||||
_listeners.input = (e) => {
|
||||
if (_isMobileComposer()) {
|
||||
if (_textarea.value.includes(PHANTOM)) {
|
||||
_textarea.value = _strip(_textarea.value);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// ── 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 (_composing) return;
|
||||
if (_isEffectivelyEmpty()) {
|
||||
// No real text left — forward backspace to PTY
|
||||
_cancelDebouncedFlush();
|
||||
_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.
|
||||
// Delete on non-empty text outside composition = dictation rewrite.
|
||||
// The IME is revising provisional text — switch to long debounce.
|
||||
_enterDictationMode();
|
||||
if (!_textarea.value.startsWith(PHANTOM)) {
|
||||
_textarea.value = PHANTOM + _textarea.value;
|
||||
_textarea.setSelectionRange(1, 1);
|
||||
}
|
||||
_debouncedFlush();
|
||||
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.
|
||||
// insertReplacementText = dictation/autocorrect refinement
|
||||
if (e.inputType === 'insertReplacementText') {
|
||||
_enterDictationMode();
|
||||
_debouncedFlush();
|
||||
return;
|
||||
}
|
||||
// Outside composition: send immediately
|
||||
_flush();
|
||||
|
||||
if (_composing) return;
|
||||
|
||||
// Keydown handler already sent this character — just clear the
|
||||
// textarea echo that the IME inserted despite preventDefault.
|
||||
if (performance.now() - _keydownSentAt < 100) {
|
||||
_resetToPhantom();
|
||||
return;
|
||||
}
|
||||
|
||||
// Outside composition: keyboard typing or voice dictation.
|
||||
// If dictation mode was detected (delete/replacement events seen
|
||||
// recently), use long debounce. Otherwise short debounce for keyboard.
|
||||
_debouncedFlush();
|
||||
};
|
||||
_textarea.addEventListener('input', _listeners.input);
|
||||
|
||||
@@ -300,18 +302,16 @@ const CjkInput = (() => {
|
||||
},
|
||||
|
||||
destroy() {
|
||||
_cancelDebouncedFlush();
|
||||
clearTimeout(_dictationDecayTimer);
|
||||
_dictationActive = false;
|
||||
if (_textarea) {
|
||||
for (const [event, handler] of Object.entries(_listeners)) {
|
||||
if (handler) _textarea.removeEventListener(event, handler);
|
||||
}
|
||||
}
|
||||
if (_xtermTextarea && _listeners.xtermFocusRedirect) {
|
||||
_xtermTextarea.removeEventListener('focus', _listeners.xtermFocusRedirect, { capture: true });
|
||||
}
|
||||
window.cjkActive = false;
|
||||
_composing = false;
|
||||
_terminalContainer = null;
|
||||
_xtermTextarea = null;
|
||||
for (const key of Object.keys(_listeners)) delete _listeners[key];
|
||||
_initialized = false;
|
||||
},
|
||||
|
||||
@@ -4,10 +4,10 @@
|
||||
* Defines two exports:
|
||||
*
|
||||
* - KeyboardAccessoryBar (singleton object) — Quick action buttons shown above the virtual
|
||||
* keyboard on mobile: arrow up/down, /init, /clear, paste, Esc, and dismiss.
|
||||
* keyboard on mobile: arrow up/down, /init, /clear, /compact, paste, Esc, and dismiss.
|
||||
* The paste button opens a dialog that handles both text paste and image attach
|
||||
* (native picker + best-effort image paste, routed through app._uploadAndInsertImages).
|
||||
* Destructive actions (/clear) require double-tap confirmation (2s amber state).
|
||||
* Destructive actions (/clear, /compact) require double-tap confirmation (2s amber state).
|
||||
* Commands are sent as text + Enter separately for Ink compatibility.
|
||||
* Only initializes on touch devices (MobileDetection.isTouchDevice guard).
|
||||
*
|
||||
@@ -58,6 +58,7 @@ const KeyboardAccessoryBar = {
|
||||
</svg>
|
||||
</button>
|
||||
<button class="accessory-btn" data-action="esc" title="Escape">Esc</button>
|
||||
<button class="accessory-btn" data-action="compact" title="/compact">/compact</button>
|
||||
<button class="accessory-btn accessory-btn-dismiss" data-action="dismiss" title="Dismiss keyboard">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3">
|
||||
<path d="M19 9l-7 7-7-7"/>
|
||||
@@ -100,6 +101,7 @@ const KeyboardAccessoryBar = {
|
||||
<button class="accessory-btn" data-action="esc" title="Escape">Esc</button>
|
||||
<button class="accessory-btn" data-action="init" title="/init">/init</button>
|
||||
<button class="accessory-btn" data-action="clear" title="/clear">/clear</button>
|
||||
<button class="accessory-btn" data-action="compact" title="/compact">/compact</button>
|
||||
<button class="accessory-btn accessory-btn-dismiss" data-action="dismiss" title="Dismiss keyboard">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3">
|
||||
<path d="M19 9l-7 7-7-7"/>
|
||||
@@ -129,7 +131,7 @@ const KeyboardAccessoryBar = {
|
||||
// Refocus terminal so keyboard stays open (tap blurs terminal → keyboard dismisses → toolbar shifts)
|
||||
const refocusActions = new Set(['scroll-up', 'scroll-down', 'arrow-left', 'arrow-right', 'tab', 'shift-tab', 'ctrl-o', 'opt-enter', 'esc', 'effort-max']);
|
||||
if (refocusActions.has(action) ||
|
||||
(action === 'clear' && this._confirmAction)) {
|
||||
((action === 'clear' || action === 'compact') && this._confirmAction)) {
|
||||
if (typeof app !== 'undefined' && app.terminal) {
|
||||
app.terminal.focus();
|
||||
}
|
||||
@@ -192,11 +194,12 @@ const KeyboardAccessoryBar = {
|
||||
case 'init':
|
||||
this.sendCommand('/init');
|
||||
break;
|
||||
case 'clear': {
|
||||
// Require double-tap: first tap turns amber, second tap within 2s sends
|
||||
case 'clear':
|
||||
case 'compact': {
|
||||
const cmd = action === 'clear' ? '/clear' : '/compact';
|
||||
if (this._confirmAction === action && this._confirmTimer) {
|
||||
this.clearConfirm();
|
||||
this.sendCommand('/clear');
|
||||
this.sendCommand(cmd);
|
||||
} else {
|
||||
this.setConfirm(action, btn);
|
||||
}
|
||||
@@ -300,7 +303,10 @@ const KeyboardAccessoryBar = {
|
||||
const sendText = () => {
|
||||
const text = textarea.value;
|
||||
close();
|
||||
if (text) app.sendInput(text);
|
||||
if (text) {
|
||||
app.sendInput(text);
|
||||
setTimeout(() => app.sendInput('\r'), 80);
|
||||
}
|
||||
};
|
||||
|
||||
// Filter to images, close the dialog, and hand off to the shared
|
||||
|
||||
@@ -291,57 +291,60 @@ const KeyboardHandler = {
|
||||
updateLayoutForKeyboard() {
|
||||
if (!window.visualViewport) return;
|
||||
|
||||
// Only adjust on mobile
|
||||
if (!MobileDetection.isSmallScreen() && !MobileDetection.isMediumScreen()) {
|
||||
if (!MobileDetection.isTouchDevice()) {
|
||||
this.resetLayout();
|
||||
return;
|
||||
}
|
||||
|
||||
const toolbar = document.querySelector('.toolbar');
|
||||
const accessoryBar = document.querySelector('.keyboard-accessory-bar');
|
||||
const cjkInput = document.getElementById('cjkInput');
|
||||
const main = document.querySelector('.main');
|
||||
const isSmallMedium = MobileDetection.isSmallScreen() || MobileDetection.isMediumScreen();
|
||||
|
||||
if (this.keyboardVisible) {
|
||||
// Calculate how far the toolbar (position:fixed, bottom:0) needs to
|
||||
// translate up so it sits at the bottom of the visual viewport.
|
||||
// This formula accounts for iOS scrolling the visual viewport (offsetTop)
|
||||
// when the user types in xterm's hidden textarea.
|
||||
//
|
||||
// MUST measure against the LAYOUT viewport (window.innerHeight): the
|
||||
// bars are position:fixed, which anchors to the layout viewport — on
|
||||
// iOS that keeps its full height while the keyboard is open. Measuring
|
||||
// the shrunken .app instead (its height tracks --app-height = visual
|
||||
// viewport) made the offset compute to 0 on iOS, leaving the toolbar
|
||||
// and accessory bar behind the OS keyboard (0.9.8 regression). On
|
||||
// Android the layout viewport itself shrinks with the keyboard, so
|
||||
// innerHeight === visualBottom and the offset is naturally 0 there.
|
||||
const layoutHeight = window.innerHeight;
|
||||
const visualBottom = window.visualViewport.offsetTop + window.visualViewport.height;
|
||||
const keyboardOffset = Math.max(0, layoutHeight - visualBottom);
|
||||
|
||||
// Move toolbar and accessory bar above keyboard.
|
||||
// When keyboardOffset is 0 (viewport scrolled to layout bottom),
|
||||
// the bars are naturally positioned via their CSS bottom values —
|
||||
// just clear the transforms. Never dismiss keyboard state here;
|
||||
// that's handleViewportResize's job.
|
||||
if (toolbar) {
|
||||
toolbar.style.transform = keyboardOffset > 0 ? `translateY(${-keyboardOffset}px)` : '';
|
||||
}
|
||||
if (accessoryBar) {
|
||||
accessoryBar.style.transform = keyboardOffset > 0 ? `translateY(${-keyboardOffset}px)` : '';
|
||||
}
|
||||
if (cjkInput?.classList.contains('cjk-input-visible')) {
|
||||
cjkInput.style.transform = keyboardOffset > 0 ? `translateY(${-keyboardOffset}px)` : '';
|
||||
}
|
||||
|
||||
// Reserve only Codeman's visible controls. The OS keyboard is outside
|
||||
// the visual viewport; adding its height here creates a large blank area
|
||||
// above the mobile toolbar on iPhone.
|
||||
const keyboardHeight = this.initialViewportHeight - (window.visualViewport.height || window.innerHeight);
|
||||
if (main && keyboardHeight > 0) {
|
||||
const cjkInputHeight = cjkInput?.classList.contains('cjk-input-visible') ? 44 : 0;
|
||||
main.style.paddingBottom = `${84 + cjkInputHeight}px`;
|
||||
const accessoryBar = document.querySelector('.keyboard-accessory-bar');
|
||||
|
||||
if (isSmallMedium) {
|
||||
// Phones/small tablets: toolbar and accessory bar are position:fixed
|
||||
// via CSS. Use translateY to lift them above the keyboard.
|
||||
const toolbar = document.querySelector('.toolbar');
|
||||
const main = document.querySelector('.main');
|
||||
|
||||
const layoutHeight = window.innerHeight;
|
||||
const visualBottom = window.visualViewport.offsetTop + window.visualViewport.height;
|
||||
const keyboardOffset = Math.max(0, layoutHeight - visualBottom);
|
||||
|
||||
if (toolbar) {
|
||||
toolbar.style.transform = keyboardOffset > 0 ? `translateY(${-keyboardOffset}px)` : '';
|
||||
}
|
||||
if (accessoryBar) {
|
||||
accessoryBar.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`;
|
||||
}
|
||||
} else if (keyboardHeight > 0) {
|
||||
// iPad: use direct bottom positioning (translateY unreliable —
|
||||
// iOS auto-scrolls the visual viewport, making keyboardOffset ≈ 0).
|
||||
if (accessoryBar) {
|
||||
accessoryBar.style.bottom = `${keyboardHeight}px`;
|
||||
}
|
||||
}
|
||||
|
||||
// CJK textarea positioning (always position:fixed on touch devices).
|
||||
if (cjkInput?.classList.contains('cjk-input-visible') && keyboardHeight > 0) {
|
||||
if (isSmallMedium) {
|
||||
// Phones: use translateY like toolbar/accessory bar.
|
||||
const layoutHeight = window.innerHeight;
|
||||
const visualBottom = window.visualViewport.offsetTop + window.visualViewport.height;
|
||||
const keyboardOffset = Math.max(0, layoutHeight - visualBottom);
|
||||
cjkInput.style.transform = keyboardOffset > 0 ? `translateY(${-keyboardOffset}px)` : '';
|
||||
cjkInput.style.bottom = '';
|
||||
} else {
|
||||
// iPad: direct bottom = keyboard + accessory bar height.
|
||||
cjkInput.style.bottom = `${keyboardHeight + 44}px`;
|
||||
cjkInput.style.transform = '';
|
||||
}
|
||||
}
|
||||
} else {
|
||||
this.resetLayout();
|
||||
@@ -360,9 +363,11 @@ const KeyboardHandler = {
|
||||
}
|
||||
if (accessoryBar) {
|
||||
accessoryBar.style.transform = '';
|
||||
accessoryBar.style.bottom = '';
|
||||
}
|
||||
if (cjkInput) {
|
||||
cjkInput.style.transform = '';
|
||||
cjkInput.style.bottom = '';
|
||||
}
|
||||
if (main) {
|
||||
main.style.paddingBottom = '';
|
||||
|
||||
+4
-168
@@ -1065,85 +1065,7 @@ html.mobile-init .file-browser-panel {
|
||||
}
|
||||
|
||||
/* Paste overlay for iOS clipboard access */
|
||||
.paste-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
z-index: 10000;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
padding-top: 15vh;
|
||||
}
|
||||
|
||||
.paste-dialog {
|
||||
background: var(--bg-secondary, #1e1e2e);
|
||||
border: 1px solid var(--border-color, #444);
|
||||
border-radius: 12px;
|
||||
padding: 12px;
|
||||
width: calc(100% - 24px);
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.paste-textarea {
|
||||
width: 100%;
|
||||
min-height: 80px;
|
||||
max-height: 200px;
|
||||
background: var(--bg-primary, #0d0d14);
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
border: 1px solid var(--border-color, #444);
|
||||
border-radius: 8px;
|
||||
padding: 8px;
|
||||
font-family: inherit;
|
||||
font-size: 16px;
|
||||
resize: none;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.paste-textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-color, #7aa2f7);
|
||||
}
|
||||
|
||||
.paste-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.paste-cancel, .paste-new, .paste-send, .paste-image {
|
||||
padding: 8px 18px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Image attach button — left-aligned, accent outline */
|
||||
.paste-image {
|
||||
margin-right: auto;
|
||||
background: var(--bg-tertiary, #333);
|
||||
color: var(--accent-color, #7aa2f7);
|
||||
border: 1px solid var(--accent-color, #7aa2f7);
|
||||
}
|
||||
|
||||
.paste-cancel {
|
||||
background: var(--bg-tertiary, #333);
|
||||
color: var(--text-secondary, #aaa);
|
||||
}
|
||||
|
||||
.paste-new {
|
||||
background: var(--bg-tertiary, #333);
|
||||
color: var(--accent-color, #7aa2f7);
|
||||
border: 1px solid var(--accent-color, #7aa2f7);
|
||||
}
|
||||
|
||||
.paste-send {
|
||||
background: var(--accent-color, #7aa2f7);
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
/* Paste overlay styles extracted to universal section below (line ~2293+) */
|
||||
|
||||
/* LEGACY: Hide old toolbar select (no longer used on mobile) */
|
||||
.toolbar-select {
|
||||
@@ -2177,95 +2099,9 @@ html.mobile-init .file-browser-panel {
|
||||
}
|
||||
|
||||
|
||||
/* ============================================================================
|
||||
Keyboard Accessory Bar — all mobile/tablet sizes
|
||||
Visual styles extracted from phone breakpoint so they apply on iPad too.
|
||||
Phone-specific positioning (position: fixed) remains in @media (max-width: 430px).
|
||||
============================================================================ */
|
||||
.keyboard-accessory-bar {
|
||||
display: none;
|
||||
height: 44px;
|
||||
background: #1a1a1a;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
padding: 6px 8px;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
z-index: 51;
|
||||
}
|
||||
|
||||
.keyboard-accessory-bar.visible {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.keyboard-accessory-bar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.accessory-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
gap: 4px;
|
||||
padding: 6px 12px;
|
||||
background: #2a2a2a;
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
border-radius: 6px;
|
||||
color: #e5e5e5;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.accessory-btn.confirming {
|
||||
background: #6b4f00;
|
||||
border-color: #b8860b;
|
||||
color: #ffd54f;
|
||||
}
|
||||
|
||||
.accessory-btn:active {
|
||||
background: #3a3a3a;
|
||||
}
|
||||
|
||||
.accessory-btn svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.accessory-btn-arrow {
|
||||
padding: 6px 10px;
|
||||
background: #2563eb;
|
||||
border-color: rgba(59, 130, 246, 0.5);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.accessory-btn-arrow:active {
|
||||
background: #1d4ed8;
|
||||
}
|
||||
|
||||
.accessory-btn-dismiss {
|
||||
margin-left: auto;
|
||||
flex: 1 1 0;
|
||||
max-width: 80px;
|
||||
padding: 10px 8px;
|
||||
background: #334d6e;
|
||||
border-color: rgba(100, 150, 200, 0.4);
|
||||
color: #c0d4e8;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.accessory-btn-dismiss svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.accessory-btn-dismiss:active {
|
||||
background: #3d5f85;
|
||||
}
|
||||
/* Keyboard accessory bar + paste overlay base styles moved to styles.css
|
||||
(always loaded — covers iPad landscape where mobile.css doesn't load).
|
||||
Phone-specific overrides remain in @media (max-width: 430px) above. */
|
||||
|
||||
/* ============================================================================
|
||||
iOS Safari Specific Fixes
|
||||
|
||||
@@ -318,6 +318,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
settings.ultracodeFloatingWindows ?? defaults.ultracodeFloatingWindows ?? false;
|
||||
document.getElementById('appSettingsShowMultiMonitorButton').checked = settings.showMultiMonitorButton ?? defaults.showMultiMonitorButton ?? false;
|
||||
document.getElementById('appSettingsShowPlanUsageLimits').checked = settings.showPlanUsageLimits ?? defaults.showPlanUsageLimits ?? false;
|
||||
document.getElementById('appSettingsShowRedrawButton').checked = settings.showRedrawButton ?? defaults.showRedrawButton ?? false;
|
||||
// Gesture control lives in the Input section (alongside Local Echo / CJK Input)
|
||||
// but is only available when the instance runs with CODEMAN_GESTURE=1 (server sets
|
||||
// window.__codemanGestureAvailable). Hide just this item otherwise so the toggle
|
||||
@@ -1176,6 +1177,48 @@ Object.assign(CodemanApp.prototype, {
|
||||
} catch {
|
||||
/* non-JSON body — use the default message */
|
||||
}
|
||||
// 403 = the no-password safety refusal (COD-55). Warn loudly and let the
|
||||
// operator acknowledge the risk; on confirm, retry with explicit acknowledgment.
|
||||
if (res.status === 403) {
|
||||
const confirmed = confirm(
|
||||
'⚠️ SECURITY WARNING — no password set\n\n' +
|
||||
'Enabling the Cloudflare tunnel will publish THIS machine to a public URL with ' +
|
||||
'NO login. Anyone who gets the URL has full terminal control — effectively remote ' +
|
||||
'code execution on your computer.\n\n' +
|
||||
'Strongly recommended: set CODEMAN_PASSWORD instead.\n\n' +
|
||||
'Enable the unauthenticated public tunnel anyway?'
|
||||
);
|
||||
if (!confirmed) {
|
||||
this._dismissTunnelConnecting?.();
|
||||
this.showToast('Tunnel not enabled', 'info');
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
const retry = await fetch('/api/settings', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ tunnelEnabled: true, acknowledgeUnauthTunnel: true }),
|
||||
});
|
||||
if (retry.ok) {
|
||||
this.showToast('Public tunnel enabling — no password set ⚠️', 'warning');
|
||||
return false; // proceed with the caller's success/connecting path
|
||||
}
|
||||
let m = 'Failed to enable tunnel.';
|
||||
try {
|
||||
const b = await retry.json();
|
||||
if (b && b.error) m = b.error;
|
||||
} catch {
|
||||
/* non-JSON */
|
||||
}
|
||||
this._dismissTunnelConnecting?.();
|
||||
this.showToast(m, 'error');
|
||||
return true;
|
||||
} catch {
|
||||
this._dismissTunnelConnecting?.();
|
||||
this.showToast('Failed to enable tunnel', 'error');
|
||||
return true;
|
||||
}
|
||||
}
|
||||
this._dismissTunnelConnecting?.();
|
||||
this.showToast(message, 'error');
|
||||
return true;
|
||||
@@ -1375,6 +1418,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
ultracodeFloatingWindows: document.getElementById('appSettingsUltracodeFloatingWindows').checked,
|
||||
showMultiMonitorButton: document.getElementById('appSettingsShowMultiMonitorButton').checked,
|
||||
showPlanUsageLimits: document.getElementById('appSettingsShowPlanUsageLimits').checked,
|
||||
showRedrawButton: document.getElementById('appSettingsShowRedrawButton').checked,
|
||||
gestureControlEnabled: document.getElementById('appSettingsGestureControl').checked,
|
||||
subagentTrackingEnabled: document.getElementById('appSettingsSubagentTracking').checked,
|
||||
subagentActiveTabOnly: document.getElementById('appSettingsSubagentActiveTabOnly').checked,
|
||||
@@ -1692,6 +1736,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
showMultiMonitorButton: false,
|
||||
showPlanUsageLimits: false,
|
||||
showAttachmentsButton: false,
|
||||
showRedrawButton: false,
|
||||
// Input
|
||||
gestureControlEnabled: false,
|
||||
// Feature toggles - keep tracking on even on mobile
|
||||
@@ -1831,6 +1876,12 @@ Object.assign(CodemanApp.prototype, {
|
||||
planUsageChip.classList.toggle('header-plan-usage--hidden', !showPlanUsageLimits);
|
||||
}
|
||||
|
||||
const showRedrawButton = settings.showRedrawButton ?? defaults.showRedrawButton ?? false;
|
||||
const redrawBtn = document.querySelector('.btn-redraw-terminal');
|
||||
if (redrawBtn) {
|
||||
redrawBtn.classList.toggle('btn-redraw-terminal--hidden', !showRedrawButton);
|
||||
}
|
||||
|
||||
// Notification bell is retired (notifications live in Settings → Notifications
|
||||
// + the drawer); keep it hidden regardless of the notification-enabled state.
|
||||
const notifBtn = document.querySelector('.btn-notifications');
|
||||
@@ -2064,7 +2115,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
// are NOT display keys — they control server-side behavior and must sync from server.
|
||||
const displayKeys = new Set([
|
||||
'showFontControls', 'showSystemStats', 'showTokenCount', 'showCost',
|
||||
'showLifecycleLog', 'showResponseViewer',
|
||||
'showLifecycleLog', 'showResponseViewer', 'showRedrawButton',
|
||||
'showMonitor', 'showProjectInsights', 'showFileBrowser', 'showSubagents',
|
||||
'subagentActiveTabOnly', 'tabTwoRows', 'localEchoEnabled', 'cjkInputEnabled', 'extendedKeyboardBar',
|
||||
'skin', 'showPlanUsageLimits', 'showAttachmentsButton',
|
||||
|
||||
+271
-3
@@ -996,6 +996,12 @@ body {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Redraw terminal header button: hidden by default (opt-in via App Settings →
|
||||
Display → "Header Displays"). Pure client-side toggle. */
|
||||
.btn-redraw-terminal--hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.btn-icon-header.btn-settings {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
@@ -2229,6 +2235,34 @@ body.solo-mode .btn-lifecycle-log {
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
/* Touch devices: prevent browser from claiming the touch gesture before
|
||||
our JS touchmove handler fires. Without this, the browser starts native
|
||||
scrolling during the first few px of finger travel and ignores our
|
||||
subsequent preventDefault() — breaking custom terminal scroll. */
|
||||
.touch-device .terminal-container,
|
||||
.touch-device .terminal-container .xterm,
|
||||
.touch-device .terminal-container .xterm-viewport,
|
||||
.touch-device .terminal-container .xterm-screen {
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
/* Mobile terminal taps are cursor-positioning gestures, not text-selection gestures.
|
||||
xterm re-enables selectable DOM text in its accessibility tree, so force the
|
||||
whole terminal subtree back to non-selectable on touch devices. Synthetic mouse
|
||||
events for tap-to-position still dispatch normally through JavaScript. */
|
||||
body.touch-device .terminal-container .xterm,
|
||||
body.touch-device .terminal-container .xterm .xterm-viewport,
|
||||
body.touch-device .terminal-container .xterm .xterm-screen,
|
||||
body.touch-device .terminal-container .xterm .xterm-rows,
|
||||
body.touch-device .terminal-container .xterm .xterm-accessibility-tree,
|
||||
body.touch-device .terminal-container .xterm .xterm-accessibility-tree *,
|
||||
body.touch-device .terminal-container .xterm .xterm-selection,
|
||||
body.touch-device .terminal-container .xterm .xterm-helper-textarea {
|
||||
user-select: none !important;
|
||||
-webkit-user-select: none !important;
|
||||
-webkit-touch-callout: none !important;
|
||||
}
|
||||
|
||||
/* Welcome Overlay */
|
||||
.welcome-overlay {
|
||||
position: absolute;
|
||||
@@ -2719,6 +2753,13 @@ body.solo-mode .btn-lifecycle-log {
|
||||
contain: style;
|
||||
}
|
||||
|
||||
/* backdrop-filter creates a stacking context, trapping the popover's
|
||||
z-index inside the toolbar. When the popover is open, raise the toolbar
|
||||
above the CJK input (z-index 52) so the popover is interactable. */
|
||||
.toolbar:has(.case-settings-popover:not(.hidden)) {
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.toolbar-left,
|
||||
.toolbar-right {
|
||||
display: flex;
|
||||
@@ -3007,6 +3048,11 @@ body.solo-mode .btn-lifecycle-log {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Touch devices (iPad, tablets): show stop button — no physical Ctrl+C */
|
||||
.touch-device .btn-toolbar.btn-stop {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
/* Mobile app settings gear - hidden on desktop, shown on mobile */
|
||||
.btn-settings-mobile {
|
||||
display: none;
|
||||
@@ -9307,9 +9353,9 @@ kbd {
|
||||
bottom: calc(var(--safe-area-bottom) + 40px);
|
||||
z-index: 52;
|
||||
display: block;
|
||||
min-height: 44px;
|
||||
max-height: 96px;
|
||||
padding: 12px 10px;
|
||||
min-height: 34px;
|
||||
max-height: 68px;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid rgba(80, 120, 190, 0.55);
|
||||
border-left: none;
|
||||
border-right: none;
|
||||
@@ -9328,6 +9374,193 @@ body.touch-device.cjk-input-visible .main {
|
||||
padding-bottom: calc(84px + var(--safe-area-bottom));
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════
|
||||
Keyboard Accessory Bar
|
||||
Base styles in styles.css (always loaded) so iPad landscape (≥1024px,
|
||||
where mobile.css doesn't load) still gets dark styling. Phone overrides
|
||||
remain in mobile.css @media (max-width: 430px).
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
.keyboard-accessory-bar {
|
||||
display: none;
|
||||
height: 44px;
|
||||
background: #1a1a1a;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
padding: 6px 8px;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
z-index: 51;
|
||||
}
|
||||
|
||||
.keyboard-accessory-bar.visible {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.keyboard-accessory-bar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Touch devices: fixed positioning to float above keyboard.
|
||||
Phone CSS (mobile.css) adds bottom + safe-area padding;
|
||||
iPad JS sets bottom dynamically via updateLayoutForKeyboard(). */
|
||||
.touch-device .keyboard-accessory-bar {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
transition: transform 0.15s ease-out;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.accessory-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
gap: 4px;
|
||||
padding: 6px 12px;
|
||||
background: #2a2a2a;
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
border-radius: 6px;
|
||||
color: #e5e5e5;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.accessory-btn.confirming {
|
||||
background: #6b4f00;
|
||||
border-color: #b8860b;
|
||||
color: #ffd54f;
|
||||
}
|
||||
|
||||
.accessory-btn:active {
|
||||
background: #3a3a3a;
|
||||
}
|
||||
|
||||
.accessory-btn svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.accessory-btn-arrow {
|
||||
padding: 6px 10px;
|
||||
background: #2563eb;
|
||||
border-color: rgba(59, 130, 246, 0.5);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.accessory-btn-arrow:active {
|
||||
background: #1d4ed8;
|
||||
}
|
||||
|
||||
.accessory-btn-dismiss {
|
||||
margin-left: auto;
|
||||
flex: 1 1 0;
|
||||
max-width: 100px;
|
||||
padding: 10px 8px;
|
||||
background: #2563eb;
|
||||
border-color: rgba(59, 130, 246, 0.5);
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.accessory-btn-dismiss svg {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
.accessory-btn-dismiss:active {
|
||||
background: #1d4ed8;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════
|
||||
Paste Overlay
|
||||
Base styles in styles.css so iPad landscape works.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
.paste-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
z-index: 10000;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
padding-top: 15vh;
|
||||
}
|
||||
|
||||
.paste-dialog {
|
||||
background: var(--bg-secondary, #1e1e2e);
|
||||
border: 1px solid var(--border-color, #444);
|
||||
border-radius: 12px;
|
||||
padding: 12px;
|
||||
width: calc(100% - 24px);
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.paste-textarea {
|
||||
width: 100%;
|
||||
min-height: 80px;
|
||||
max-height: 200px;
|
||||
background: var(--bg-primary, #0d0d14);
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
border: 1px solid var(--border-color, #444);
|
||||
border-radius: 8px;
|
||||
padding: 8px;
|
||||
font-family: inherit;
|
||||
font-size: 16px;
|
||||
resize: none;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.paste-textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-color, #7aa2f7);
|
||||
}
|
||||
|
||||
.paste-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.paste-cancel, .paste-new, .paste-send, .paste-image {
|
||||
padding: 8px 18px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.paste-image {
|
||||
margin-right: auto;
|
||||
background: var(--bg-tertiary, #333);
|
||||
color: var(--accent-color, #7aa2f7);
|
||||
border: 1px solid var(--accent-color, #7aa2f7);
|
||||
}
|
||||
|
||||
.paste-cancel {
|
||||
background: var(--bg-tertiary, #333);
|
||||
color: var(--text-secondary, #aaa);
|
||||
}
|
||||
|
||||
.paste-new {
|
||||
background: var(--bg-tertiary, #333);
|
||||
color: var(--accent-color, #7aa2f7);
|
||||
border: 1px solid var(--accent-color, #7aa2f7);
|
||||
}
|
||||
|
||||
.paste-send {
|
||||
background: var(--accent-color, #7aa2f7);
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════
|
||||
Orchestrator Panel
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
@@ -10199,3 +10432,38 @@ html:not([data-skin="og"]) {
|
||||
/* ---- Focus rings (driven by --accent, now emerald) stay accessible ---- */
|
||||
:focus-visible { outline-color: var(--accent); }
|
||||
}
|
||||
|
||||
/* ---- Daylight Blue: distinct identity per welcome action button ----
|
||||
Run Claude Code keeps the blue accent; Cloudflare Tunnel is purple; Run
|
||||
OpenCode is emerald green — so the three are clearly different colors instead
|
||||
of all reading blue. Scoped to daylight-blue only; placed after the shared
|
||||
daylight block to win on equal specificity. ---- */
|
||||
html[data-skin="daylight-blue"] .welcome-btn-opencode {
|
||||
background: linear-gradient(135deg, #0d9f6e, #2fbf85);
|
||||
border-color: rgba(16, 185, 129, 0.5);
|
||||
color: #04291b;
|
||||
}
|
||||
html[data-skin="daylight-blue"] .welcome-btn-opencode:hover {
|
||||
background: linear-gradient(135deg, #10b981, #34d399);
|
||||
border-color: rgba(52, 211, 153, 0.6);
|
||||
box-shadow: 0 0 28px -4px rgba(16, 185, 129, 0.4);
|
||||
}
|
||||
html[data-skin="daylight-blue"] .welcome-btn-tunnel {
|
||||
background: linear-gradient(135deg, #7c3aed, #a855f7);
|
||||
border-color: rgba(168, 85, 247, 0.5);
|
||||
color: #f5f3ff;
|
||||
}
|
||||
html[data-skin="daylight-blue"] .welcome-btn-tunnel:hover {
|
||||
background: linear-gradient(135deg, #8b5cf6, #c084fc);
|
||||
border-color: rgba(192, 132, 252, 0.6);
|
||||
box-shadow: 0 0 28px -4px rgba(124, 58, 237, 0.45);
|
||||
}
|
||||
html[data-skin="daylight-blue"] .welcome-btn-tunnel.active {
|
||||
background: linear-gradient(135deg, #6d28d9, #7c3aed);
|
||||
border-color: rgba(124, 58, 237, 0.6);
|
||||
color: #f5f3ff;
|
||||
}
|
||||
html[data-skin="daylight-blue"] .welcome-btn-tunnel.active:hover {
|
||||
background: linear-gradient(135deg, #7c3aed, #8b5cf6);
|
||||
box-shadow: 0 0 28px -4px rgba(124, 58, 237, 0.5);
|
||||
}
|
||||
|
||||
+121
-79
@@ -19,6 +19,10 @@
|
||||
// suppressed, so high-frequency Codex status redraws don't snap the viewport
|
||||
// back to the bottom while the user is inspecting earlier output.
|
||||
const USER_SCROLL_STICKY_SUPPRESS_MS = 1500;
|
||||
// Mobile browsers synthesize trusted mouse events after touchend. During this
|
||||
// short window, only the app's synthetic tap-to-position mouse event should
|
||||
// reach xterm.
|
||||
const TOUCH_COMPAT_MOUSE_SUPPRESS_MS = 450;
|
||||
|
||||
function isTerminalQueryResponse(data) {
|
||||
return TERMINAL_QUERY_RESPONSE_PATTERN.test(data) || TERMINAL_OSC_RESPONSE_PATTERN.test(data);
|
||||
@@ -46,6 +50,7 @@
|
||||
isTerminalQueryResponse,
|
||||
shouldSuppressTerminalQueryResponse,
|
||||
USER_SCROLL_STICKY_SUPPRESS_MS,
|
||||
TOUCH_COMPAT_MOUSE_SUPPRESS_MS,
|
||||
};
|
||||
global.CODEMAN_XTERM_THEMES = CODEMAN_XTERM_THEMES;
|
||||
global.codemanCurrentXtermTheme = currentXtermTheme;
|
||||
@@ -107,6 +112,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
const container = document.getElementById('terminalContainer');
|
||||
this.terminal.open(container);
|
||||
this._installMobileTapMouseGuard();
|
||||
|
||||
// Suppress xterm key handling during CJK IME composition.
|
||||
// Without this, xterm processes raw keyDown events (e.g., "Process" key)
|
||||
@@ -365,11 +371,14 @@ Object.assign(CodemanApp.prototype, {
|
||||
let pixelAccum = 0;
|
||||
|
||||
let didScroll = false; // track whether touchmove fired (tap vs scroll)
|
||||
let touchStartY = 0;
|
||||
const TAP_THRESHOLD = 8; // px — ignore micro-drift to distinguish tap from scroll
|
||||
container.addEventListener(
|
||||
'touchstart',
|
||||
(ev) => {
|
||||
if (ev.touches.length === 1) {
|
||||
touchLastY = ev.touches[0].clientY;
|
||||
touchStartY = touchLastY;
|
||||
velocity = 0;
|
||||
pixelAccum = 0;
|
||||
isTouching = true;
|
||||
@@ -388,9 +397,18 @@ Object.assign(CodemanApp.prototype, {
|
||||
'touchmove',
|
||||
(ev) => {
|
||||
if (ev.touches.length === 1 && isTouching) {
|
||||
ev.preventDefault();
|
||||
didScroll = true;
|
||||
const touchY = ev.touches[0].clientY;
|
||||
if (!didScroll && Math.abs(touchY - touchStartY) >= TAP_THRESHOLD) {
|
||||
didScroll = true;
|
||||
}
|
||||
// Below the tap threshold, treat the gesture as a potential tap:
|
||||
// don't preventDefault (iOS needs click synthesis to show the
|
||||
// keyboard) and don't accumulate scroll distance or velocity. Without
|
||||
// this guard, sub-threshold micro-drift still scrolls a line and
|
||||
// leaves a non-zero velocity that touchend turns into a momentum
|
||||
// fling, so a jittery tap would both position the cursor AND scroll.
|
||||
if (!didScroll) return;
|
||||
ev.preventDefault();
|
||||
const delta = touchLastY - touchY; // positive = scroll down
|
||||
pixelAccum += delta;
|
||||
velocity = delta * 1.2;
|
||||
@@ -410,20 +428,38 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
container.addEventListener(
|
||||
'touchend',
|
||||
() => {
|
||||
(ev) => {
|
||||
isTouching = false;
|
||||
if (!scrollFrame && Math.abs(velocity) > 0.3) {
|
||||
scrollFrame = requestAnimationFrame(scrollLoop);
|
||||
}
|
||||
// Tap (no scroll): refocus xterm's hidden textarea so keyboard input
|
||||
// routes back to the terminal. Without this, a tap on the terminal area
|
||||
// consumes the touch event but xterm's textarea never regains focus.
|
||||
if (!didScroll && this.terminal) {
|
||||
// ── Tap-to-position cursor ──────────────────────────────────
|
||||
// Synthesize a click from the real touch point so the foreground app
|
||||
// moves its cursor to the tapped cell (iOS doesn't reliably do this
|
||||
// itself under touch-action:none). CRITICAL: only when mouse tracking
|
||||
// is ON. xterm disables its local SelectionService while mouse events
|
||||
// are active, so the synthetic click is forwarded to the PTY as an SGR
|
||||
// report (cursor moves). But when tracking is OFF, that same click
|
||||
// drives xterm's LOCAL selection (detail 1/2/3 → char/word/line) — a
|
||||
// tap on CJK text would select & copy it instead of positioning. So
|
||||
// gate strictly on the live mouse-tracking mode.
|
||||
const touch = ev.changedTouches && ev.changedTouches[0];
|
||||
const mouseMode = this.terminal.modes?.mouseTrackingMode;
|
||||
const mouseTrackingOn = !!mouseMode && mouseMode !== 'none';
|
||||
if (touch) {
|
||||
this._suppressTrustedTapMouseEvents();
|
||||
}
|
||||
if (touch && mouseTrackingOn) {
|
||||
this._dispatchSyntheticTerminalClick(touch.clientX, touch.clientY);
|
||||
}
|
||||
this._syncMobileHelperTextareaToCursor();
|
||||
// Route subsequent typing to the right place: keep the CJK input
|
||||
// field focused when Chinese input is on, otherwise the terminal.
|
||||
const cjkInput = document.getElementById('cjkInput');
|
||||
if (cjkInput?.classList.contains('cjk-input-visible')) {
|
||||
cjkInput.focus();
|
||||
} else {
|
||||
this._syncMobileHelperTextareaToCursor();
|
||||
this.terminal.focus();
|
||||
}
|
||||
}
|
||||
@@ -585,8 +621,13 @@ Object.assign(CodemanApp.prototype, {
|
||||
// survives tab switches and reconnects.
|
||||
|
||||
this.terminal.onData((data) => {
|
||||
// CJK input has focus — block xterm from sending to PTY
|
||||
if (window.cjkActive || document.activeElement?.id === 'cjkInput') return;
|
||||
// Mouse SGR reports (tap-to-position) are NOT IME input — they must reach
|
||||
// the PTY even while the CJK input field owns focus. Without this exception
|
||||
// tapping to move the cursor silently does nothing whenever Chinese input
|
||||
// is on, because cjkActive stays true the whole time the field is visible.
|
||||
const isMouseReport = /^\x1b\[<\d+;\d+;\d+[Mm]$/.test(data);
|
||||
// CJK input has focus — block xterm from sending keystrokes to PTY
|
||||
if (!isMouseReport && (window.cjkActive || document.activeElement?.id === 'cjkInput')) return;
|
||||
if (this.activeSessionId) {
|
||||
// Filter terminal query replies generated by xterm.js itself.
|
||||
// Forwarding them through the WebSocket injects DA/DSR/CPR replies
|
||||
@@ -1577,71 +1618,11 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
},
|
||||
|
||||
// CJK textarea already provides visual feedback — bypass local echo
|
||||
// buffering so each composed word reaches the PTY immediately.
|
||||
_handleCjkInput(text) {
|
||||
if (!this.activeSessionId) return;
|
||||
const sessionId = this.activeSessionId;
|
||||
const session = this.sessions.get(sessionId);
|
||||
const useLocalEcho = !!(this._localEchoEnabled && this._localEchoOverlay && session?.mode !== 'shell');
|
||||
if (!useLocalEcho) {
|
||||
this._sendInputAsync(sessionId, text);
|
||||
return;
|
||||
}
|
||||
|
||||
if (text === '\x7f') {
|
||||
const source = this._localEchoOverlay.removeChar();
|
||||
if (source === 'flushed') {
|
||||
// Sync app-level flushed Maps (per-session state for tab switching),
|
||||
// mirroring the onData backspace path — otherwise switching tabs away
|
||||
// and back restores a stale, too-long flushed overlay.
|
||||
const { count, text: flushedText } = this._localEchoOverlay.getFlushed();
|
||||
if (this._flushedOffsets?.has(sessionId)) {
|
||||
if (count === 0) {
|
||||
this._flushedOffsets.delete(sessionId);
|
||||
this._flushedTexts?.delete(sessionId);
|
||||
} else {
|
||||
this._flushedOffsets.set(sessionId, count);
|
||||
this._flushedTexts?.set(sessionId, flushedText);
|
||||
}
|
||||
}
|
||||
this._sendInputAsync(sessionId, text);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (/[\r\n]+$/.test(text)) {
|
||||
const committed = text.replace(/[\r\n]+$/g, '');
|
||||
if (committed) this._localEchoOverlay.appendText(committed);
|
||||
const pending = this._localEchoOverlay.pendingText || '';
|
||||
this._localEchoOverlay.clear();
|
||||
this._localEchoOverlay.suppressBufferDetection();
|
||||
this._flushedOffsets?.delete(sessionId);
|
||||
this._flushedTexts?.delete(sessionId);
|
||||
if (pending) this._sendInputAsync(sessionId, pending);
|
||||
setTimeout(() => this._sendInputAsync(sessionId, '\r'), pending ? 80 : 0);
|
||||
return;
|
||||
}
|
||||
|
||||
// Multi-byte escape sequence (arrow/Home/End from a hardware keyboard on
|
||||
// the composer) — forward to the PTY without touching overlay state,
|
||||
// mirroring the onData path. Appending it to pending text would type raw
|
||||
// ESC bytes into the prompt on the next Enter.
|
||||
if (text.length > 1 && text.charCodeAt(0) === 27) {
|
||||
this._sendInputAsync(sessionId, text);
|
||||
return;
|
||||
}
|
||||
|
||||
if (text.length === 1 && text.charCodeAt(0) < 32) {
|
||||
const pending = this._localEchoOverlay.pendingText || '';
|
||||
this._localEchoOverlay.clear();
|
||||
this._localEchoOverlay.suppressBufferDetection();
|
||||
this._flushedOffsets?.delete(sessionId);
|
||||
this._flushedTexts?.delete(sessionId);
|
||||
if (pending) this._sendInputAsync(sessionId, pending);
|
||||
this._sendInputAsync(sessionId, text);
|
||||
return;
|
||||
}
|
||||
|
||||
this._localEchoOverlay.appendText(text);
|
||||
this._sendInputAsync(this.activeSessionId, text);
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -1995,9 +1976,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
|
||||
try {
|
||||
// Send resize to restore proper dimensions (with minimum enforcement).
|
||||
// The PTY's SIGWINCH on real dim change is enough for Ink to redraw.
|
||||
await this.sendResize(this.activeSessionId);
|
||||
// Force resize even when dimensions match the server's last known state —
|
||||
// another device may have changed the PTY size since this client last sent,
|
||||
// and force guarantees a SIGWINCH → Ink redraw at the current device's size.
|
||||
await this.sendResize(this.activeSessionId, { force: true });
|
||||
|
||||
this.showToast(`Terminal restored to ${dims.cols}x${dims.rows}`, 'success');
|
||||
} catch (err) {
|
||||
@@ -2054,6 +2036,62 @@ Object.assign(CodemanApp.prototype, {
|
||||
} catch {}
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Synthetic tap → mouse report
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Dispatch a mousedown+mouseup pair at viewport coords (clientX/clientY) to
|
||||
// xterm's root element. xterm's mouse-reporting handler reads the event's
|
||||
// client coords, maps them to a terminal cell relative to .xterm-screen, and
|
||||
// — when the foreground app has mouse tracking active (DECSET 1000/1002/1006,
|
||||
// which Claude's input enables) — encodes an SGR mouse report to the PTY.
|
||||
// That is the same path a real desktop click takes; on touch devices the
|
||||
// browser's own compatibility-event synthesis is unreliable (and suppressed
|
||||
// by touch-action:none), so we drive it explicitly. With mouse tracking off
|
||||
// it degrades to a harmless zero-length click (no drag → no text selection).
|
||||
_dispatchSyntheticTerminalClick(clientX, clientY) {
|
||||
const el = this.terminal?.element;
|
||||
if (!el || !Number.isFinite(clientX) || !Number.isFinite(clientY)) return;
|
||||
// xterm registers its mouseup listener on document during mousedown, so a
|
||||
// bubbling mouseup reaches it; dispatch both to the root element in order.
|
||||
const base = {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
view: window,
|
||||
clientX,
|
||||
clientY,
|
||||
screenX: clientX,
|
||||
screenY: clientY,
|
||||
button: 0,
|
||||
detail: 1,
|
||||
};
|
||||
try {
|
||||
el.dispatchEvent(new MouseEvent('mousedown', { ...base, buttons: 1 }));
|
||||
el.dispatchEvent(new MouseEvent('mouseup', { ...base, buttons: 0 }));
|
||||
} catch {
|
||||
/* MouseEvent constructor unavailable — tap-to-position simply no-ops */
|
||||
}
|
||||
},
|
||||
|
||||
_installMobileTapMouseGuard() {
|
||||
const el = this.terminal?.element;
|
||||
if (!el || el._codemanTapMouseGuardInstalled) return;
|
||||
if (typeof MobileDetection !== 'undefined' && MobileDetection.isTouchDevice && !MobileDetection.isTouchDevice()) return;
|
||||
el._codemanTapMouseGuardInstalled = true;
|
||||
const suppressTrustedCompatMouse = (ev) => {
|
||||
const suppressUntil = this._trustedTapMouseSuppressUntil || 0;
|
||||
if (!ev.isTrusted || performance.now() > suppressUntil) return;
|
||||
ev.preventDefault();
|
||||
ev.stopImmediatePropagation();
|
||||
};
|
||||
el.addEventListener('mousedown', suppressTrustedCompatMouse, true);
|
||||
el.addEventListener('mouseup', suppressTrustedCompatMouse, true);
|
||||
},
|
||||
|
||||
_suppressTrustedTapMouseEvents() {
|
||||
const ms = window.CodemanTerminalInput?.TOUCH_COMPAT_MOUSE_SUPPRESS_MS || 450;
|
||||
this._trustedTapMouseSuppressUntil = performance.now() + ms;
|
||||
},
|
||||
|
||||
increaseFontSize() {
|
||||
const current = this.terminal.options.fontSize || 14;
|
||||
this.setFontSize(Math.min(current + 2, 24));
|
||||
@@ -2103,8 +2141,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
/**
|
||||
* Send resize to a session with minimum dimension enforcement.
|
||||
* @param {string} sessionId
|
||||
* @param {{ forceHttp?: boolean }} [options]
|
||||
* @returns {Promise<void>}
|
||||
* @param {{ forceHttp?: boolean, force?: boolean }} [options]
|
||||
* @returns {Promise<boolean>} Whether dimensions changed from the last send
|
||||
*/
|
||||
async sendResize(sessionId, options = {}) {
|
||||
// Fit terminal to container before reading dimensions — ensures local
|
||||
@@ -2133,16 +2171,20 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Fast path: WebSocket resize
|
||||
if (!options.forceHttp && this._wsReady && this._wsSessionId === sessionId) {
|
||||
try {
|
||||
this._ws.send(JSON.stringify({ t: 'z', c: dims.cols, r: dims.rows, v: viewportType }));
|
||||
const msg = { t: 'z', c: dims.cols, r: dims.rows, v: viewportType };
|
||||
if (options.force) msg.f = true;
|
||||
this._ws.send(JSON.stringify(msg));
|
||||
return changed;
|
||||
} catch {
|
||||
// Fall through to HTTP POST
|
||||
}
|
||||
}
|
||||
const body = { ...dims, viewportType };
|
||||
if (options.force) body.force = true;
|
||||
await fetch(`/api/sessions/${sessionId}/resize`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ...dims, viewportType }),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
return changed;
|
||||
},
|
||||
|
||||
@@ -78,8 +78,15 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (panel.classList.contains('open')) this.renderUltracodeAgentsPanel();
|
||||
},
|
||||
closeUltracodeAgentsPanel() {
|
||||
// The X must FULLY hide the panel. Removing only `open` drops it to the
|
||||
// collapsed peek state (header strip still visible), so add `hidden`
|
||||
// (display:none) too — mirrors closeSubagentsPanel. Not the showUltracodeAgents
|
||||
// setting: that also gates the watcher + floating windows; the launcher reopens.
|
||||
const panel = document.getElementById('ultracodeAgentsPanel');
|
||||
if (panel) panel.classList.remove('open');
|
||||
if (panel) {
|
||||
panel.classList.remove('open');
|
||||
panel.classList.add('hidden');
|
||||
}
|
||||
},
|
||||
|
||||
// ----- Selection -----
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
*
|
||||
* This is the "floating thing" companion to the docked master-detail panel in
|
||||
* `ultracode-panel.js` (the dock panel stays — these windows are ADDITIONAL).
|
||||
* When the `showUltracodeAgents` setting is on, a small floating window pops up
|
||||
* When the `ultracodeFloatingWindows` setting is on (a DEDICATED toggle, separate
|
||||
* from the dock panel's `showUltracodeAgents` — see `_ultracodeFloatingEnabled`),
|
||||
* a small floating window pops up
|
||||
* for each ACTIVE ultracode/Workflow run (status not completed/killed/failed),
|
||||
* mirroring the live agent grid, and is connected by a glowing line to the
|
||||
* Codeman tab whose `claudeSessionId` matches the run's `sessionUuid` — the same
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
type ApiResponse,
|
||||
type SessionColor,
|
||||
} from '../../types.js';
|
||||
import { Session } from '../../session.js';
|
||||
import { Session, isAltScreenStripMode } from '../../session.js';
|
||||
import { SseEvent } from '../sse-events.js';
|
||||
import {
|
||||
CreateSessionSchema,
|
||||
@@ -79,12 +79,13 @@ const LEADING_WHITESPACE_PATTERN = /^[\s\r\n]+/;
|
||||
* (1049 also saves cursor and clears the alt buffer).
|
||||
* - CSI 3 J = erase saved lines (scrollback).
|
||||
*
|
||||
* Codex emits `\x1b[?1049h` and clear-scrollback sequences during startup and
|
||||
* on repaint. xterm.js obeys them by switching to the alt buffer (no native
|
||||
* scrollback) and wiping saved lines, so the user's conversation history
|
||||
* disappears on every tab switch / pane refresh. Stripping these from the
|
||||
* replayed byte stream keeps everything in the main buffer with scrollback
|
||||
* intact. Mirrors the live-stream strip in Session._handleTerminalOutput.
|
||||
* Codex AND Claude Code emit `\x1b[?1049h` and clear-scrollback sequences (the
|
||||
* latter intermittently, e.g. full-screen pickers/dialogs). xterm.js obeys them
|
||||
* by switching to the alt buffer (no native scrollback) and wiping saved lines,
|
||||
* so the user's conversation history disappears on every tab switch / pane
|
||||
* refresh (and scroll-up breaks live). Stripping these from the replayed byte
|
||||
* stream keeps everything in the main buffer with scrollback intact. Mirrors the
|
||||
* live-stream strip in Session._handleTerminalOutput (isAltScreenStripMode).
|
||||
*/
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const ALT_SCREEN_TOGGLE_PATTERN = /\x1b\[\?(?:47|1047|1049)[hl]/g;
|
||||
@@ -745,14 +746,10 @@ export function registerSessionRoutes(
|
||||
|
||||
app.post('/api/sessions/:id/resize', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const { cols, rows, viewportType } = parseBody(ResizeSchema, req.body);
|
||||
const { cols, rows, viewportType, force } = parseBody(ResizeSchema, req.body);
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
|
||||
if (viewportType) {
|
||||
session.resize(cols, rows, { viewportType });
|
||||
} else {
|
||||
session.resize(cols, rows);
|
||||
}
|
||||
session.resize(cols, rows, { viewportType, force });
|
||||
return {};
|
||||
});
|
||||
|
||||
@@ -989,10 +986,11 @@ export function registerSessionRoutes(
|
||||
// the terminal appears empty when switching tabs.
|
||||
let strippedBuffer = stripInkRedrawBloat(rawBuffer);
|
||||
|
||||
// Strip alt-screen toggles and scrollback-erase from codex byte streams.
|
||||
// xterm.js obeys them by switching to its scrollback-less alt buffer and
|
||||
// wiping saved lines, so conversation history disappears on tab switch.
|
||||
if (session.mode === 'codex') {
|
||||
// Strip alt-screen toggles and scrollback-erase from Codex/Claude byte
|
||||
// streams. xterm.js obeys them by switching to its scrollback-less alt
|
||||
// buffer and wiping saved lines, so conversation history disappears on tab
|
||||
// switch. Same gate as the live-stream strip in session.ts.
|
||||
if (isAltScreenStripMode(session.mode)) {
|
||||
strippedBuffer = strippedBuffer
|
||||
.replace(ALT_SCREEN_TOGGLE_PATTERN, '')
|
||||
.replace(ERASE_SCROLLBACK_PATTERN, '')
|
||||
|
||||
@@ -505,20 +505,34 @@ export function registerSystemRoutes(
|
||||
// control = effectively RCE) to a public *.trycloudflare.com URL. Because the
|
||||
// tunnel binds to loopback, server.ts's non-loopback bind guard never trips, and
|
||||
// with no CODEMAN_PASSWORD the auth middleware is inactive — so the tunnel URL is
|
||||
// unauthenticated. Refuse to start a tunnel unless auth is configured OR the
|
||||
// operator has acknowledged unauthenticated-network exposure. A public tunnel is
|
||||
// higher-stakes than a LAN bind, so this is REFUSE (vs the bind guard's warn).
|
||||
// unauthenticated. Refuse to start a tunnel unless auth is configured OR exposure
|
||||
// is acknowledged: either the CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK env var, or an
|
||||
// explicit per-request `acknowledgeUnauthTunnel:true` (the UI sends this after a
|
||||
// confirm dialog). This keeps curl/API/CLI callers protected by default while
|
||||
// letting an operator opt in from the browser without setting the env var.
|
||||
// Guard runs BEFORE persisting so a refused tunnelEnabled:true is not saved.
|
||||
if (settings.tunnelEnabled === true && !ctx.tunnelManager.isRunning() && !isUnauthenticatedNetworkAcknowledged()) {
|
||||
const msg =
|
||||
'Refusing to start the Cloudflare tunnel without authentication: it would publish ' +
|
||||
'full terminal control to a public URL with no password. Set CODEMAN_PASSWORD to ' +
|
||||
'require login, or set CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1 to acknowledge an ' +
|
||||
'unauthenticated public tunnel.';
|
||||
throw Object.assign(new Error(msg), {
|
||||
statusCode: 403,
|
||||
body: createErrorResponse(ApiErrorCode.OPERATION_FAILED, msg),
|
||||
});
|
||||
if (settings.tunnelEnabled === true && !ctx.tunnelManager.isRunning()) {
|
||||
const acknowledged = isUnauthenticatedNetworkAcknowledged() || settings.acknowledgeUnauthTunnel === true;
|
||||
if (!acknowledged) {
|
||||
const msg =
|
||||
'Refusing to start the Cloudflare tunnel without authentication: it would publish ' +
|
||||
'full terminal control to a public URL with no password. Set CODEMAN_PASSWORD to ' +
|
||||
'require login, set CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1, or resend with ' +
|
||||
'acknowledgeUnauthTunnel:true to acknowledge an unauthenticated public tunnel.';
|
||||
throw Object.assign(new Error(msg), {
|
||||
statusCode: 403,
|
||||
body: createErrorResponse(ApiErrorCode.OPERATION_FAILED, msg),
|
||||
});
|
||||
}
|
||||
// Loud warning whenever a public tunnel is started with no password — whether
|
||||
// acknowledged via env var or the per-request UI confirmation.
|
||||
if (!process.env.CODEMAN_PASSWORD) {
|
||||
console.warn(
|
||||
'⚠️ [tunnel] Starting an UNAUTHENTICATED public Cloudflare tunnel — no CODEMAN_PASSWORD set. ' +
|
||||
'Anyone with the tunnel URL gets full terminal control (effectively RCE). ' +
|
||||
'Set CODEMAN_PASSWORD to require login.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -532,9 +546,9 @@ export function registerSystemRoutes(
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
// statusLineTelemetry is an ACTION field (reconcile the plan-usage exporter),
|
||||
// not a stored setting — strip it before persisting so settings.json stays clean.
|
||||
const { statusLineTelemetry, ...settingsToStore } = settings;
|
||||
// statusLineTelemetry and acknowledgeUnauthTunnel are ACTION fields (not stored
|
||||
// settings) — strip them before persisting so settings.json stays clean.
|
||||
const { statusLineTelemetry, acknowledgeUnauthTunnel, ...settingsToStore } = settings;
|
||||
const merged = { ...existing, ...settingsToStore };
|
||||
await fs.writeFile(SETTINGS_PATH, JSON.stringify(merged, null, 2));
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
* {"t":"r"} — needs refresh (reload buffer)
|
||||
* Client -> Server:
|
||||
* {"t":"i","d":"..."} — input (keystroke or paste)
|
||||
* {"t":"z","c":N,"r":N} — resize terminal
|
||||
* {"t":"z","c":N,"r":N,"f":bool} — resize terminal (f=true forces SIGWINCH even if dims unchanged)
|
||||
*/
|
||||
|
||||
import { FastifyInstance } from 'fastify';
|
||||
@@ -146,11 +146,8 @@ export function registerWsRoutes(app: FastifyInstance, ctx: SessionPort, getHost
|
||||
session.releaseDesktopSizing(sizingToken);
|
||||
holdsDesktopClaim = false;
|
||||
}
|
||||
if (viewportType) {
|
||||
session.resize(msg.c, msg.r, { viewportType });
|
||||
} else {
|
||||
session.resize(msg.c, msg.r);
|
||||
}
|
||||
const force = msg.f === true;
|
||||
session.resize(msg.c, msg.r, { viewportType, force });
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed messages
|
||||
|
||||
@@ -186,6 +186,7 @@ export const ResizeSchema = z.object({
|
||||
cols: z.number().int().min(1).max(500),
|
||||
rows: z.number().int().min(1).max(200),
|
||||
viewportType: z.enum(['mobile', 'tablet', 'desktop']).optional(),
|
||||
force: z.boolean().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -354,6 +355,11 @@ export const SettingsUpdateSchema = z
|
||||
ultracodeFloatingWindows: z.boolean().optional(),
|
||||
imageWatcherEnabled: z.boolean().optional(),
|
||||
tunnelEnabled: z.boolean().optional(),
|
||||
// Action field (NOT persisted): explicit per-request acknowledgment that the
|
||||
// operator accepts exposing an UNAUTHENTICATED public tunnel (no CODEMAN_PASSWORD).
|
||||
// Lets the UI enable a tunnel after a confirm dialog without the
|
||||
// CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK env var. Stripped before persisting.
|
||||
acknowledgeUnauthTunnel: z.boolean().optional(),
|
||||
tabTwoRows: z.boolean().optional(),
|
||||
agentTeamsEnabled: z.boolean().optional(),
|
||||
/** Model for new Claude sessions (e.g. "claude-fable-5[1m]", "opus[1m]"); takes precedence over opusContext1mEnabled */
|
||||
@@ -379,6 +385,7 @@ export const SettingsUpdateSchema = z
|
||||
// (client-side), but telemetry COLLECTION is server-side, so the per-device
|
||||
// toggle signals it out-of-band here rather than via showPlanUsageLimits.
|
||||
statusLineTelemetry: z.boolean().optional(),
|
||||
showRedrawButton: z.boolean().optional(),
|
||||
// Input
|
||||
gestureControlEnabled: z.boolean().optional(),
|
||||
// Claude CLI settings
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { Session, isAltScreenStripMode } from '../src/session.js';
|
||||
|
||||
type SessionInternals = {
|
||||
_handleTerminalOutput(data: string): void;
|
||||
};
|
||||
|
||||
function handleOutput(session: Session, data: string): void {
|
||||
(session as unknown as SessionInternals)._handleTerminalOutput(data);
|
||||
}
|
||||
|
||||
describe('isAltScreenStripMode', () => {
|
||||
it('strips for the controlled TUIs (codex + claude), not shell/opencode', () => {
|
||||
expect(isAltScreenStripMode('codex')).toBe(true);
|
||||
expect(isAltScreenStripMode('claude')).toBe(true);
|
||||
expect(isAltScreenStripMode('shell')).toBe(false);
|
||||
expect(isAltScreenStripMode('opencode')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Claude terminal scrollback strip', () => {
|
||||
it('strips alt-screen toggles, scrollback-erase, and mouse-tracking', () => {
|
||||
const session = new Session({ workingDir: '/tmp', mode: 'claude' });
|
||||
const emitted: string[] = [];
|
||||
session.on('terminal', (data) => emitted.push(data));
|
||||
|
||||
handleOutput(session, '\x1b[?1049h\x1b[55;1Hdialog\x1b[3J\x1b[?1006h\x1b[?1049l');
|
||||
|
||||
expect(emitted[0]).toBe('\x1b[55;1Hdialog');
|
||||
expect(session.terminalBuffer).toBe('\x1b[55;1Hdialog');
|
||||
});
|
||||
|
||||
it('keeps the visible-screen erase (2J / [J) — only scrollback-erase (3J) is dropped', () => {
|
||||
const session = new Session({ workingDir: '/tmp', mode: 'claude' });
|
||||
|
||||
handleOutput(session, '\x1b[?1049h\x1b[2Jvisible\x1b[3Jscrollback\x1b[?1049l');
|
||||
|
||||
expect(session.terminalBuffer).toBe('\x1b[2Jvisiblescrollback');
|
||||
});
|
||||
|
||||
it('preserves an ordinary erase-display redraw (no scrollback sequences)', () => {
|
||||
const session = new Session({ workingDir: '/tmp', mode: 'claude' });
|
||||
|
||||
handleOutput(session, '\x1b[H\x1b[Jclaude redraw');
|
||||
|
||||
expect(session.terminalBuffer).toBe('\x1b[H\x1b[Jclaude redraw');
|
||||
});
|
||||
|
||||
it('strips sequences split across PTY chunk boundaries (carry reassembly)', () => {
|
||||
const session = new Session({ workingDir: '/tmp', mode: 'claude' });
|
||||
const emitted: string[] = [];
|
||||
session.on('terminal', (data) => emitted.push(data));
|
||||
|
||||
handleOutput(session, 'before\x1b[?104');
|
||||
handleOutput(session, '9h\x1b[2Jafter\x1b[3');
|
||||
handleOutput(session, 'Jtail');
|
||||
|
||||
expect(session.terminalBuffer).toBe('before\x1b[2Jaftertail');
|
||||
expect(emitted).toEqual(['before', '\x1b[2Jafter', 'tail']);
|
||||
});
|
||||
|
||||
it('emits nothing for a chunk that is only a partial CSI, then completes it', () => {
|
||||
const session = new Session({ workingDir: '/tmp', mode: 'claude' });
|
||||
const emitted: string[] = [];
|
||||
session.on('terminal', (data) => emitted.push(data));
|
||||
|
||||
handleOutput(session, '\x1b[?100'); // pure partial — held, nothing emitted
|
||||
handleOutput(session, '6h done'); // completes ?1006h (stripped); rest passes
|
||||
|
||||
expect(emitted).toEqual([' done']);
|
||||
expect(session.terminalBuffer).toBe(' done');
|
||||
});
|
||||
|
||||
it('does not touch ordinary Claude conversation output', () => {
|
||||
const session = new Session({ workingDir: '/tmp', mode: 'claude' });
|
||||
|
||||
const text = 'Here is line one\r\nHere is line two\r\n\x1b[2mdim status\x1b[0m';
|
||||
handleOutput(session, text);
|
||||
|
||||
expect(session.terminalBuffer).toBe(text);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Shell terminal output is NOT stripped (vim/less/htop need the alt screen)', () => {
|
||||
it('leaves alt-screen toggles, scrollback-erase, and mouse-tracking intact for shell', () => {
|
||||
const session = new Session({ workingDir: '/tmp', mode: 'shell' });
|
||||
|
||||
const vimLike = '\x1b[?1049h\x1b[?1002h\x1b[2J~ editing\x1b[3J\x1b[?1002l\x1b[?1049l';
|
||||
handleOutput(session, vimLike);
|
||||
|
||||
expect(session.terminalBuffer).toBe(vimLike);
|
||||
});
|
||||
});
|
||||
@@ -369,7 +369,7 @@ describe('session-routes', () => {
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(true);
|
||||
expect(harness.ctx._session.resize).toHaveBeenCalledWith(120, 40);
|
||||
expect(harness.ctx._session.resize).toHaveBeenCalledWith(120, 40, { viewportType: undefined, force: undefined });
|
||||
});
|
||||
|
||||
it('passes viewport type through for resize arbitration', async () => {
|
||||
@@ -381,7 +381,19 @@ describe('session-routes', () => {
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(true);
|
||||
expect(harness.ctx._session.resize).toHaveBeenCalledWith(48, 28, { viewportType: 'mobile' });
|
||||
expect(harness.ctx._session.resize).toHaveBeenCalledWith(48, 28, { viewportType: 'mobile', force: undefined });
|
||||
});
|
||||
|
||||
it('passes force resize through for redraw requests', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/resize`,
|
||||
payload: { cols: 120, rows: 40, force: true },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(true);
|
||||
expect(harness.ctx._session.resize).toHaveBeenCalledWith(120, 40, { viewportType: undefined, force: true });
|
||||
});
|
||||
|
||||
it('rejects cols exceeding max (500)', async () => {
|
||||
|
||||
@@ -117,6 +117,35 @@ describe('COD-55 tunnel password guard (PUT /api/settings tunnelEnabled)', () =>
|
||||
expect(tunnel.start).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('ALLOWS tunnel-enable with per-request acknowledgeUnauthTunnel:true (start called, 200, flag not persisted)', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/settings',
|
||||
payload: { tunnelEnabled: true, acknowledgeUnauthTunnel: true },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(tunnel.start).toHaveBeenCalledTimes(1);
|
||||
// The action flag must NOT be persisted to settings.json.
|
||||
expect(mockedWriteFile).toHaveBeenCalled();
|
||||
const persisted = JSON.parse(mockedWriteFile.mock.calls[0][1] as string);
|
||||
expect(persisted.acknowledgeUnauthTunnel).toBeUndefined();
|
||||
expect(persisted.tunnelEnabled).toBe(true);
|
||||
});
|
||||
|
||||
it('still REFUSES when acknowledgeUnauthTunnel is false (4xx, start not called)', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/settings',
|
||||
payload: { tunnelEnabled: true, acknowledgeUnauthTunnel: false },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBeGreaterThanOrEqual(400);
|
||||
expect(res.statusCode).toBeLessThan(500);
|
||||
expect(tunnel.start).not.toHaveBeenCalled();
|
||||
expect(mockedWriteFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not guard tunnel-disable (tunnelEnabled:false always allowed)', async () => {
|
||||
tunnel = makeTunnelManager(true);
|
||||
(harness.ctx as unknown as { tunnelManager: unknown }).tunnelManager = tunnel;
|
||||
|
||||
@@ -285,7 +285,7 @@ describe('ws-routes', () => {
|
||||
ws.send(JSON.stringify({ t: 'z', c: 120, r: 40 }));
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(session.resize).toHaveBeenCalledWith(120, 40);
|
||||
expect(session.resize).toHaveBeenCalledWith(120, 40, { viewportType: undefined, force: false });
|
||||
});
|
||||
} finally {
|
||||
ws.close();
|
||||
@@ -300,7 +300,22 @@ describe('ws-routes', () => {
|
||||
ws.send(JSON.stringify({ t: 'z', c: 48, r: 28, v: 'mobile' }));
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(session.resize).toHaveBeenCalledWith(48, 28, { viewportType: 'mobile' });
|
||||
expect(session.resize).toHaveBeenCalledWith(48, 28, { viewportType: 'mobile', force: false });
|
||||
});
|
||||
} finally {
|
||||
ws.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('passes force resize through for redraw requests', async () => {
|
||||
const ws = await connectWs('/ws/sessions/ws-test-session/terminal');
|
||||
try {
|
||||
const session = ctx._session;
|
||||
|
||||
ws.send(JSON.stringify({ t: 'z', c: 120, r: 40, f: true }));
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(session.resize).toHaveBeenCalledWith(120, 40, { viewportType: undefined, force: true });
|
||||
});
|
||||
} finally {
|
||||
ws.close();
|
||||
@@ -342,7 +357,7 @@ describe('ws-routes', () => {
|
||||
ws.send(JSON.stringify({ t: 'z', c: 1, r: 1 }));
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(session.resize).toHaveBeenCalledWith(1, 1);
|
||||
expect(session.resize).toHaveBeenCalledWith(1, 1, { viewportType: undefined, force: false });
|
||||
});
|
||||
} finally {
|
||||
ws.close();
|
||||
@@ -357,7 +372,7 @@ describe('ws-routes', () => {
|
||||
ws.send(JSON.stringify({ t: 'z', c: 500, r: 200 }));
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(session.resize).toHaveBeenCalledWith(500, 200);
|
||||
expect(session.resize).toHaveBeenCalledWith(500, 200, { viewportType: undefined, force: false });
|
||||
});
|
||||
} finally {
|
||||
ws.close();
|
||||
|
||||
@@ -113,6 +113,15 @@ describe('Session resize arbitration', () => {
|
||||
expect(resize).toHaveBeenCalledWith(100, 30);
|
||||
});
|
||||
|
||||
it('applies forced resizes even when the dimensions did not change', () => {
|
||||
const session = new Session({ workingDir: '/tmp', mode: 'shell' });
|
||||
const resize = attachFakePty(session, 120, 40);
|
||||
|
||||
session.resize(120, 40, { force: true });
|
||||
|
||||
expect(resize).toHaveBeenCalledWith(120, 40);
|
||||
});
|
||||
|
||||
describe('idle-desktop override (whoever is active wins)', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
function loadTerminalUiHarness() {
|
||||
const CodemanApp = function CodemanApp(this: any) {};
|
||||
let now = 1_000;
|
||||
const context = vm.createContext({
|
||||
window: {},
|
||||
CodemanApp,
|
||||
console: { warn: vi.fn(), log: vi.fn() },
|
||||
_crashDiag: { log: vi.fn() },
|
||||
performance: { now: () => now },
|
||||
requestAnimationFrame: (_fn: () => void) => 1,
|
||||
setTimeout: (_fn: () => void) => 1,
|
||||
Blob: function Blob() {},
|
||||
URL: {
|
||||
createObjectURL: () => 'blob:yield',
|
||||
revokeObjectURL: () => {},
|
||||
},
|
||||
Worker: function Worker(this: any) {
|
||||
this.postMessage = () => {};
|
||||
},
|
||||
MobileDetection: {
|
||||
isTouchDevice: () => true,
|
||||
},
|
||||
DEC_SYNC_STRIP_RE: /\x1b\[\?2026[hl]/g,
|
||||
TERMINAL_CHUNK_SIZE: 32 * 1024,
|
||||
});
|
||||
|
||||
const code = readFileSync(resolve(import.meta.dirname, '../src/web/public/terminal-ui.js'), 'utf8');
|
||||
vm.runInContext(code, context, { filename: 'terminal-ui.js' });
|
||||
|
||||
const app = new (CodemanApp as any)();
|
||||
return {
|
||||
app,
|
||||
setNow: (value: number) => {
|
||||
now = value;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createElementHarness() {
|
||||
const listeners = new Map<string, (ev: any) => void>();
|
||||
return {
|
||||
element: {
|
||||
addEventListener: vi.fn((type: string, listener: (ev: any) => void) => {
|
||||
listeners.set(type, listener);
|
||||
}),
|
||||
},
|
||||
dispatch(type: string, event: any) {
|
||||
listeners.get(type)?.(event);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('terminal touch tap mouse guard', () => {
|
||||
it('suppresses browser trusted compatibility mouse events during the tap window', () => {
|
||||
const { app } = loadTerminalUiHarness();
|
||||
const { element, dispatch } = createElementHarness();
|
||||
app.terminal = { element };
|
||||
|
||||
app._installMobileTapMouseGuard();
|
||||
app._suppressTrustedTapMouseEvents();
|
||||
|
||||
const event = {
|
||||
isTrusted: true,
|
||||
preventDefault: vi.fn(),
|
||||
stopImmediatePropagation: vi.fn(),
|
||||
};
|
||||
dispatch('mousedown', event);
|
||||
|
||||
expect(event.preventDefault).toHaveBeenCalledOnce();
|
||||
expect(event.stopImmediatePropagation).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('allows the app synthetic mouse event through the tap window', () => {
|
||||
const { app } = loadTerminalUiHarness();
|
||||
const { element, dispatch } = createElementHarness();
|
||||
app.terminal = { element };
|
||||
|
||||
app._installMobileTapMouseGuard();
|
||||
app._suppressTrustedTapMouseEvents();
|
||||
|
||||
const event = {
|
||||
isTrusted: false,
|
||||
preventDefault: vi.fn(),
|
||||
stopImmediatePropagation: vi.fn(),
|
||||
};
|
||||
dispatch('mousedown', event);
|
||||
|
||||
expect(event.preventDefault).not.toHaveBeenCalled();
|
||||
expect(event.stopImmediatePropagation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows trusted mouse events after the tap window expires', () => {
|
||||
const { app, setNow } = loadTerminalUiHarness();
|
||||
const { element, dispatch } = createElementHarness();
|
||||
app.terminal = { element };
|
||||
|
||||
app._installMobileTapMouseGuard();
|
||||
app._suppressTrustedTapMouseEvents();
|
||||
setNow(2_000);
|
||||
|
||||
const event = {
|
||||
isTrusted: true,
|
||||
preventDefault: vi.fn(),
|
||||
stopImmediatePropagation: vi.fn(),
|
||||
};
|
||||
dispatch('mousedown', event);
|
||||
|
||||
expect(event.preventDefault).not.toHaveBeenCalled();
|
||||
expect(event.stopImmediatePropagation).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user