mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-08 16:39:42 +02:00
Merge pull request #240 from Ark0N/feat/predictive-echo-codex
Zero-lag predictive echo for Codex sessions (mosh-style write-through)
This commit is contained in:
@@ -0,0 +1,15 @@
|
|||||||
|
---
|
||||||
|
"aicodeman": minor
|
||||||
|
"xterm-zerolag-input": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Zero-lag predictive echo for Codex sessions (mosh-style write-through prediction).
|
||||||
|
|
||||||
|
Codex's per-keystroke composer forced 1.12.2 to disable the local-echo overlay (issues #218/#219/#220/#222), leaving Codex typing at full round-trip latency on remote links. This release adds a second echo mode instead of re-enabling the first: every keystroke still goes to the PTY exactly as before (byte-identical wire behavior, pinned by vm-level and end-to-end trace-equality tests), while the new `PredictiveEchoAddon` in `xterm-zerolag-input` 0.2.0 paints the predicted glyph at the predicted cell. When the real echo lands, the prediction is confirmed and its span removed (an invisible swap); mispredictions self-heal via a two-pass mismatch cascade and a TTL.
|
||||||
|
|
||||||
|
- Reconciliation reads the parsed terminal buffer, never the raw stream: full-line redraws, ECH gap painting and tmux's in-place deltas all converge to the same cells. Confirmation requires the cell match PLUS a cursor advance, so placeholder glyphs and identical repaints never false-confirm; blank cells are neutral (codex clears its placeholder on the first echo).
|
||||||
|
- Predictions paint only while the cursor sits on the measured Codex composer row (`/^› /`, codex-cli 0.147): trust/approval modals and wrapped continuation rows get no ghosts, deliberately falling back to real echo.
|
||||||
|
- Ships as a SEPARATE `vendor/xterm-predictive-echo.js` bundle: the existing zerolag bundle is byte-identical (sha256-verified), and a missing or broken bundle degrades Codex to exact 1.12.2 behavior. The per-device `localEchoEnabled` toggle is the kill switch.
|
||||||
|
- Claude/Gemini/OpenCode/Antigravity keep buffer mode untouched; shell stays off.
|
||||||
|
- A post-build adversarial review added the anchor-hold rule: after an unpredicted wire edit (backspace into echoed text, cleared input, IME text commits) new predictions hold until the next parsed write, so a stale displayed cursor can never mis-anchor a run.
|
||||||
|
- Tests: 55 new package tests including replay suites driven by fixtures recorded from a real codex TUI through the production tmux+strip pipeline (`scripts/dev/record-codex-frames.mjs`) and a 500-iteration seeded fuzz; new vm policy/wire-neutrality suites; a 10-scenario Playwright E2E against real codex covering the #218/#219/#220/#222 retests, byte-identity, and a simulated 300ms-RTT run. The package test suite now runs in CI.
|
||||||
@@ -91,6 +91,14 @@ jobs:
|
|||||||
# Safe in CI: TmuxManager no-ops all shell commands under VITEST (test/setup.ts).
|
# Safe in CI: TmuxManager no-ops all shell commands under VITEST (test/setup.ts).
|
||||||
run: npm run test:ci
|
run: npm run test:ci
|
||||||
|
|
||||||
|
- name: Run xterm-zerolag-input package tests
|
||||||
|
# Layers 1-3 of the predictive-echo suites (unit laws, fixture replay,
|
||||||
|
# seeded fuzz): deterministic, no browser, no live server. Depends on
|
||||||
|
# the ROOT `npm ci` above — workspaces hoist the package's vitest into
|
||||||
|
# the root node_modules; do not add a separate install here.
|
||||||
|
run: npx vitest run
|
||||||
|
working-directory: packages/xterm-zerolag-input
|
||||||
|
|
||||||
# Note: The browser-driven mobile suite (test/mobile/**) is excluded from CI —
|
# Note: The browser-driven mobile suite (test/mobile/**) is excluded from CI —
|
||||||
# it needs a live server + chromium + environment-specific PNG baselines.
|
# it needs a live server + chromium + environment-specific PNG baselines.
|
||||||
# Run it locally/manually. All other tests run via the `test` job above.
|
# Run it locally/manually. All other tests run via the `test` job above.
|
||||||
|
|||||||
@@ -129,7 +129,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
|||||||
- **Model choice flows via `settings.local.json`, NOT `--model` or env** — the App Settings **Claude Model** picker (`claudeModel` in `settings.json`) is read by `session-ui.js` at session create (wins over the legacy 1M-Opus toggles `opusContext1m`/`opusContext1mEnabled`), sent as the `modelOverride` payload field, and `updateCaseModel()` (`hooks-config.ts`) writes/deletes the `model` key in `<case>/.claude/settings.local.json`. This is the intended exception to the envOverrides rule above: model legitimately lives in `settings.local.json` (a soft default — in-session `/model` still works); env vars do not
|
- **Model choice flows via `settings.local.json`, NOT `--model` or env** — the App Settings **Claude Model** picker (`claudeModel` in `settings.json`) is read by `session-ui.js` at session create (wins over the legacy 1M-Opus toggles `opusContext1m`/`opusContext1mEnabled`), sent as the `modelOverride` payload field, and `updateCaseModel()` (`hooks-config.ts`) writes/deletes the `model` key in `<case>/.claude/settings.local.json`. This is the intended exception to the envOverrides rule above: model legitimately lives in `settings.local.json` (a soft default — in-session `/model` still works); env vars do not
|
||||||
- **Multi-CLI prefix discipline** — env-var prefix is CLI-specific (`CLAUDE_CODE_*` vs `OPENCODE_*` vs `CODEX_*` vs `GEMINI_*` vs `ANTIGRAVITY_*`) and the `ALLOWED_ENV_PREFIXES` allowlist in `schemas.ts` enforces this. Gemini additionally allowlists the **broad `GOOGLE_*`** namespace (intentional: Vertex AI auth needs `GOOGLE_CLOUD_PROJECT`/`GOOGLE_APPLICATION_CREDENTIALS`/`GOOGLE_GENAI_USE_VERTEXAI`; it is the loosest allowlist entry, affecting only the user's own spawned CLI). When adding a setting, decide which CLI(s) it applies to and gate the env export accordingly. Never blanket-forward all prefixes. Resolver design pattern: `docs/opencode-integration.md`
|
- **Multi-CLI prefix discipline** — env-var prefix is CLI-specific (`CLAUDE_CODE_*` vs `OPENCODE_*` vs `CODEX_*` vs `GEMINI_*` vs `ANTIGRAVITY_*`) and the `ALLOWED_ENV_PREFIXES` allowlist in `schemas.ts` enforces this. Gemini additionally allowlists the **broad `GOOGLE_*`** namespace (intentional: Vertex AI auth needs `GOOGLE_CLOUD_PROJECT`/`GOOGLE_APPLICATION_CREDENTIALS`/`GOOGLE_GENAI_USE_VERTEXAI`; it is the loosest allowlist entry, affecting only the user's own spawned CLI). When adding a setting, decide which CLI(s) it applies to and gate the env export accordingly. Never blanket-forward all prefixes. Resolver design pattern: `docs/opencode-integration.md`
|
||||||
- **Zod `.optional()` rejects `null`** — accepts `undefined` only. When the frontend builds a request body with `JSON.stringify`, an explicit `null` field is preserved on the wire and fails validation with `INVALID_INPUT`. Convert `null` → `undefined` before stringifying (e.g. `field: value ?? undefined`), or declare the schema `.nullish()`. This has caused real shipped bugs twice
|
- **Zod `.optional()` rejects `null`** — accepts `undefined` only. When the frontend builds a request body with `JSON.stringify`, an explicit `null` field is preserved on the wire and fails validation with `INVALID_INPUT`. Convert `null` → `undefined` before stringifying (e.g. `field: value ?? undefined`), or declare the schema `.nullish()`. This has caused real shipped bugs twice
|
||||||
- **`xterm-zerolag-input` is single-source** — the local-echo overlay source lives ONLY in `packages/xterm-zerolag-input/src/`, and is bundled into the **gitignored** `src/web/public/vendor/xterm-zerolag-input.js` (dev, by `scripts/postinstall.js`) and `dist/.../vendor/` (prod, by `scripts/build.mjs`). `app.js` only **consumes** it via `new LocalEchoOverlay(terminal)`; there is no inline copy. So: change the package source, then rerun the bundle step (`npm install` for dev, `npm run build` for prod). **Never hand-edit `app.js` for overlay behavior, and never commit the gitignored vendor bundle.** Always test on mobile after touching it. → [architecture-invariants#xterm-zerolag-input-is-single-source](docs/architecture-invariants.md#xterm-zerolag-input-is-single-source), `docs/local-echo-overlay-plan.md`
|
- **`xterm-zerolag-input` is single-source** — BOTH echo addons live ONLY in `packages/xterm-zerolag-input/src/`, bundled into TWO **gitignored** vendor files: `vendor/xterm-zerolag-input.js` (buffer overlay, entry `zerolag-input-addon.ts`) and `vendor/xterm-predictive-echo.js` (codex write-through, entry `predictive-echo-addon.ts`) — dev by `scripts/postinstall.js`, prod by `scripts/build.mjs`. `app.js`/terminal-ui.js only **consume** them via `new LocalEchoOverlay(terminal)` / `new PredictiveEchoOverlay(terminal)`; there is no inline copy. So: change the package source, then rerun the bundle step (`npm install` for dev, `npm run build` for prod). **Never hand-edit `app.js` for overlay behavior, and never commit the gitignored vendor bundles.** Always test on mobile after touching it. → [architecture-invariants#xterm-zerolag-input-is-single-source](docs/architecture-invariants.md#xterm-zerolag-input-is-single-source), `docs/local-echo-overlay-plan.md`
|
||||||
- **Default bind is loopback-only; non-loopback without a password starts but warns** — the server defaults to `--host 127.0.0.1`. Binding non-loopback (`--host`/`-H`/`CODEMAN_HOST`) without `CODEMAN_PASSWORD` starts anyway but prints a loud warning; `--allow-unauthenticated-network` / `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1` acknowledges it. ⚠️ The production systemd unit passes no `--host`, so prod binds **localhost only**: reach it via `tailscale serve`/tunnel to `127.0.0.1`. A loopback bind is reachable through a same-host tunnel but NOT by a browser hitting the box's LAN IP. `install.sh` is separate and prompts for the binding (defaulting to LAN + a password), and preserves the existing binding on re-runs. → [architecture-invariants#default-bind-and-the-non-loopback-warning-path](docs/architecture-invariants.md#default-bind-and-the-non-loopback-warning-path), `docs/security-architecture.md`
|
- **Default bind is loopback-only; non-loopback without a password starts but warns** — the server defaults to `--host 127.0.0.1`. Binding non-loopback (`--host`/`-H`/`CODEMAN_HOST`) without `CODEMAN_PASSWORD` starts anyway but prints a loud warning; `--allow-unauthenticated-network` / `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1` acknowledges it. ⚠️ The production systemd unit passes no `--host`, so prod binds **localhost only**: reach it via `tailscale serve`/tunnel to `127.0.0.1`. A loopback bind is reachable through a same-host tunnel but NOT by a browser hitting the box's LAN IP. `install.sh` is separate and prompts for the binding (defaulting to LAN + a password), and preserves the existing binding on re-runs. → [architecture-invariants#default-bind-and-the-non-loopback-warning-path](docs/architecture-invariants.md#default-bind-and-the-non-loopback-warning-path), `docs/security-architecture.md`
|
||||||
- **Instance isolation / multi-instance attach danger** — the data dir (`~/.codeman`) and tmux socket (`tmux -L codeman`) are PROCESS-WIDE and shared by every Codeman on the machine, derived from `CODEMAN_INSTANCE` via `src/config/instance.ts`. ⚠️ A 2nd instance on the SAME socket **discovers and attaches PTYs to the first instance's live sessions**, resizing and mutating them. `$HOME` isolation is NOT enough because tmux is system-global. To run two instances, give each a distinct `CODEMAN_INSTANCE` (scopes dir + socket together), or set `CODEMAN_TMUX_SOCKET` + `CODEMAN_DATA_DIR` individually; `scripts/run-beta.sh` does this for a beta alongside prod. **Any new `~/.codeman/...` path MUST go through `dataPath()`**, never `join(homedir(), '.codeman', …)`. → [architecture-invariants#instance-isolation-and-the-multi-instance-attach-danger](docs/architecture-invariants.md#instance-isolation-and-the-multi-instance-attach-danger)
|
- **Instance isolation / multi-instance attach danger** — the data dir (`~/.codeman`) and tmux socket (`tmux -L codeman`) are PROCESS-WIDE and shared by every Codeman on the machine, derived from `CODEMAN_INSTANCE` via `src/config/instance.ts`. ⚠️ A 2nd instance on the SAME socket **discovers and attaches PTYs to the first instance's live sessions**, resizing and mutating them. `$HOME` isolation is NOT enough because tmux is system-global. To run two instances, give each a distinct `CODEMAN_INSTANCE` (scopes dir + socket together), or set `CODEMAN_TMUX_SOCKET` + `CODEMAN_DATA_DIR` individually; `scripts/run-beta.sh` does this for a beta alongside prod. **Any new `~/.codeman/...` path MUST go through `dataPath()`**, never `join(homedir(), '.codeman', …)`. → [architecture-invariants#instance-isolation-and-the-multi-instance-attach-danger](docs/architecture-invariants.md#instance-isolation-and-the-multi-instance-attach-danger)
|
||||||
- **node-pty's macOS `spawn-helper` ships without `+x`** (issues #6, #204): `node-pty@1.1.0` publishes `prebuilds/darwin-<arch>/spawn-helper` as mode 0644, and macOS launches every PTY through it, so a stock macOS install fails every session start with `Error: posix_spawnp failed.` **Linux can never reproduce it**: `spawn-helper` is an `OS=="mac"` gyp target and node-pty ships no Linux prebuild, so node-gyp always emits an executable helper there. ⚠️ Look in **`prebuilds/<platform>-<arch>/`**, not just `build/Release/`, which does not exist on macOS. Repair is a chmod, never a mandatory rebuild (that would require Xcode CLI tools and deletes `prebuilds/` before compiling): `npm run fix:node-pty` chmods every helper then proves it by really opening a PTY. `spawnPtyWithHelperRepair()` (`utils/node-pty-repair.ts`) wraps every `pty.spawn()` in `session.ts` and self-heals a broken install on the first failure. → [architecture-invariants#node-ptys-macos-spawn-helper-must-be-executable](docs/architecture-invariants.md#node-ptys-macos-spawn-helper-must-be-executable)
|
- **node-pty's macOS `spawn-helper` ships without `+x`** (issues #6, #204): `node-pty@1.1.0` publishes `prebuilds/darwin-<arch>/spawn-helper` as mode 0644, and macOS launches every PTY through it, so a stock macOS install fails every session start with `Error: posix_spawnp failed.` **Linux can never reproduce it**: `spawn-helper` is an `OS=="mac"` gyp target and node-pty ships no Linux prebuild, so node-gyp always emits an executable helper there. ⚠️ Look in **`prebuilds/<platform>-<arch>/`**, not just `build/Release/`, which does not exist on macOS. Repair is a chmod, never a mandatory rebuild (that would require Xcode CLI tools and deletes `prebuilds/` before compiling): `npm run fix:node-pty` chmods every helper then proves it by really opening a PTY. `spawnPtyWithHelperRepair()` (`utils/node-pty-repair.ts`) wraps every `pty.spawn()` in `session.ts` and self-heals a broken install on the first failure. → [architecture-invariants#node-ptys-macos-spawn-helper-must-be-executable](docs/architecture-invariants.md#node-ptys-macos-spawn-helper-must-be-executable)
|
||||||
@@ -198,7 +198,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
|||||||
|
|
||||||
**Docker cases**: a case can point at a **container**, with any of the five CLI backends running inside it. Like remote-SSH this is a **LOCATION OVERLAY on cases, never a sixth `SessionMode`**. Exactly one long-lived container **per case**, shared by all its sessions, so killing a session kills only that session's in-container tmux and **never** `docker stop` while siblings remain. The workspace is a real host dir bind-mounted at the **same absolute path**, which is what keeps file-routes/watchers on real host bytes and makes the in-container transcript projHash match the host. Credentials are **seeded** (RO mount, copied into the container once) rather than shared RW, so in-container CLIs never write refreshed tokens back to the host, and bind mounts are excluded from `docker commit` so exports stay secret-free. **NEVER a create-time `-e` for secrets, NEVER `--privileged`, NEVER the docker socket.** Config drift is detected via a label hash and a drifted launch is REFUSED rather than silently launched with stale config. ⚠️ On the loopback-only prod bind a container cannot reach 127.0.0.1, so in-container hooks need `CODEMAN_DOCKER_BRIDGE_HOOKS=1`; otherwise idle detection falls back to output-based. → [architecture-invariants#docker-cases](docs/architecture-invariants.md#docker-cases), `docs/docker-cases.md` (user guide), `docs/docker-cases-plan.md` (design)
|
**Docker cases**: a case can point at a **container**, with any of the five CLI backends running inside it. Like remote-SSH this is a **LOCATION OVERLAY on cases, never a sixth `SessionMode`**. Exactly one long-lived container **per case**, shared by all its sessions, so killing a session kills only that session's in-container tmux and **never** `docker stop` while siblings remain. The workspace is a real host dir bind-mounted at the **same absolute path**, which is what keeps file-routes/watchers on real host bytes and makes the in-container transcript projHash match the host. Credentials are **seeded** (RO mount, copied into the container once) rather than shared RW, so in-container CLIs never write refreshed tokens back to the host, and bind mounts are excluded from `docker commit` so exports stay secret-free. **NEVER a create-time `-e` for secrets, NEVER `--privileged`, NEVER the docker socket.** Config drift is detected via a label hash and a drifted launch is REFUSED rather than silently launched with stale config. ⚠️ On the loopback-only prod bind a container cannot reach 127.0.0.1, so in-container hooks need `CODEMAN_DOCKER_BRIDGE_HOOKS=1`; otherwise idle detection falls back to output-based. → [architecture-invariants#docker-cases](docs/architecture-invariants.md#docker-cases), `docs/docker-cases.md` (user guide), `docs/docker-cases-plan.md` (design)
|
||||||
|
|
||||||
**External CLI modes (OpenCode, Codex, Gemini, Antigravity)**: `isExternalCliMode()` in `session.ts` gates Claude-specific behavior off (Ralph tracker, BashToolParser, token/CLI-info parsing, ❯-prompt readiness); these CLIs render their own TUIs, so readiness is output stabilization instead. All four **require tmux with no direct PTY fallback**, because secrets are injected via socket-scoped `tmux setenv` and never on the spawn command line. ⚠️ `run*()` in `session-ui.js` MUST unwrap the `{success,data}` envelope; reading the raw shape silently breaks the run. ⚠️ **The local-echo overlay is DISABLED for codex sessions** (`_updateLocalEchoState` in terminal-ui.js, same branch as shell): codex's composer reacts per keystroke ("/" pops a live-filtering picker, arrows edit server-side state, the composer grows as it wraps), so buffer-until-Enter starved it into issues #218/#219/#220/#222. Codex also **drops keystrokes that share a PTY read with a bracketed paste**, so flushed text and the paste sequence must go out as separate delayed writes (mirroring the Enter branch's delayed `\r`). Tests: `test/local-echo-codex-gating.test.ts`. → [architecture-invariants#external-cli-modes-opencode-codex-gemini](docs/architecture-invariants.md#external-cli-modes-opencode-codex-gemini)
|
**External CLI modes (OpenCode, Codex, Gemini, Antigravity)**: `isExternalCliMode()` in `session.ts` gates Claude-specific behavior off (Ralph tracker, BashToolParser, token/CLI-info parsing, ❯-prompt readiness); these CLIs render their own TUIs, so readiness is output stabilization instead. All four **require tmux with no direct PTY fallback**, because secrets are injected via socket-scoped `tmux setenv` and never on the spawn command line. ⚠️ `run*()` in `session-ui.js` MUST unwrap the `{success,data}` envelope; reading the raw shape silently breaks the run. ⚠️ **Codex sessions use PREDICTIVE WRITE-THROUGH echo, never the buffer overlay** (`_localEchoPolicy` in `_updateLocalEchoState`, terminal-ui.js): codex's composer reacts per keystroke ("/" pops a live-filtering picker, arrows edit server-side state, the composer grows as it wraps), so buffer-until-Enter starved it into issues #218/#219/#220/#222 and stays disabled (`_localEchoEnabled` remains false for codex). Instead, `PredictiveEchoAddon` (separate `vendor/xterm-predictive-echo.js` bundle) paints each keystroke at the predicted cell while the wire path stays BYTE-IDENTICAL: the onData hook (`_predictHookOnData`) is a plain statement with no `return`, so control always falls through into the untouched send path — pinned by vm and E2E byte-identity tests. Predictions reconcile against the parsed buffer and only while the cursor sits on the measured composer row (`isCodexComposerRow`, `/^› /`). Codex also **drops keystrokes that share a PTY read with a bracketed paste**, so flushed text and the paste sequence must go out as separate delayed writes (mirroring the Enter branch's delayed `\r`). Tests: `test/local-echo-codex-gating.test.ts`, `test/codex-predictive-echo.test.ts` (E2E vs real codex), `packages/xterm-zerolag-input/test/codex-replay.test.ts`. → [architecture-invariants#external-cli-modes-opencode-codex-gemini](docs/architecture-invariants.md#external-cli-modes-opencode-codex-gemini)
|
||||||
|
|
||||||
**Run launch synchronization**: the Run entrypoint holds an in-flight lock and disables `#runBtn` for the whole launch (≥500ms), so a double click cannot create duplicate sessions with the same `w<n>-<case>` name. `_ensureCreatedSessionVisible()` runs before `selectSession()`, and `_onSessionCreated()` stays an idempotent upsert, so POST-first and SSE-first ordering both produce exactly one rendered tab. → [architecture-invariants#run-launch-synchronization](docs/architecture-invariants.md#run-launch-synchronization)
|
**Run launch synchronization**: the Run entrypoint holds an in-flight lock and disables `#runBtn` for the whole launch (≥500ms), so a double click cannot create duplicate sessions with the same `w<n>-<case>` name. `_ensureCreatedSessionVisible()` runs before `selectSession()`, and `_onSessionCreated()` stays an idempotent upsert, so POST-first and SSE-first ordering both produce exactly one rendered tab. → [architecture-invariants#run-launch-synchronization](docs/architecture-invariants.md#run-launch-synchronization)
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export default defineConfig({
|
|||||||
'test/opencode-resize.test.ts', // browser (Playwright)
|
'test/opencode-resize.test.ts', // browser (Playwright)
|
||||||
'test/webgl-fallback.test.ts', // browser (Playwright)
|
'test/webgl-fallback.test.ts', // browser (Playwright)
|
||||||
'test/terminal-copy-shortcut.test.ts', // browser (Playwright)
|
'test/terminal-copy-shortcut.test.ts', // browser (Playwright)
|
||||||
|
'test/codex-predictive-echo.test.ts', // browser (Playwright) + real codex binary
|
||||||
],
|
],
|
||||||
setupFiles: ['./test/setup.ts'],
|
setupFiles: ['./test/setup.ts'],
|
||||||
fileParallelism: false,
|
fileParallelism: false,
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,143 @@
|
|||||||
|
# Predictive write-through echo for codex
|
||||||
|
|
||||||
|
Zero-lag local echo for codex sessions via a second, mosh-style mode in the
|
||||||
|
`xterm-zerolag-input` package: every keystroke goes to the PTY exactly as the
|
||||||
|
1.12.2 overlay-disabled path did (byte-identical wire behavior), while a
|
||||||
|
`PredictiveEchoAddon` simultaneously paints the predicted glyph at the predicted
|
||||||
|
cell. When the real echo lands, the prediction is confirmed and its span removed
|
||||||
|
(invisible swap: identical glyph beneath). Mispredictions drop via a mismatch
|
||||||
|
cascade + TTL. Visual-only, self-healing.
|
||||||
|
|
||||||
|
## Why this exists
|
||||||
|
|
||||||
|
Issues #218/#219/#220/#222 (one root cause) forced 1.12.2 to disable the
|
||||||
|
LocalEchoOverlay for codex: buffer-until-Enter starves codex's per-keystroke TUI
|
||||||
|
(live slash picker, arrows editing server-side composer state, composer
|
||||||
|
rewrap/growth, paste_burst classification). Buffer mode is structurally
|
||||||
|
incompatible with codex; write-through prediction is the only echo mode that
|
||||||
|
can coexist with it.
|
||||||
|
|
||||||
|
## The reconciliation lesson (do not regress this)
|
||||||
|
|
||||||
|
`docs/local-echo-overlay-plan.md` ("What NOT to Do") documented that matching
|
||||||
|
predictions against the raw output STREAM fails against Ink/TUI full-line
|
||||||
|
redraws. This design reads the parsed terminal BUFFER instead (cells after
|
||||||
|
xterm's parser ran), which converges to the same cells no matter how the bytes
|
||||||
|
arrived. The Phase 0 recordings prove the point twice over: tmux converts
|
||||||
|
codex's full-line redraws into minimal in-place deltas (an echo arrives as
|
||||||
|
`e\x1b[K\x1b[20;80H...`), and codex itself paints word gaps with ECH+cursor-forward
|
||||||
|
instead of spaces. Stream matching can never survive that; buffer diffing does
|
||||||
|
not care.
|
||||||
|
|
||||||
|
## Phase 0 measurements (codex-cli 0.147.0 via tmux, 100x30, 2026-08-09)
|
||||||
|
|
||||||
|
Recorded with `scripts/dev/record-codex-frames.mjs` (production pipeline:
|
||||||
|
codex inside tmux `status off`, chunks passed through the same full strip
|
||||||
|
`session.ts _handleTerminalOutput()` applies to codex mode). Fixtures in
|
||||||
|
`packages/xterm-zerolag-input/test/fixtures/codex/`; replay/measure with
|
||||||
|
`scripts/dev/analyze-codex-frames.mjs <fixture>`.
|
||||||
|
|
||||||
|
| Question | Measured answer |
|
||||||
|
| --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| Composer signature | Cursor row starts `"› "` (U+203A + space), text begins col 2. Present when empty (placeholder), while typing, and while the slash picker filters. `CODEX_COMPOSER_ROW_RE = /^› /` |
|
||||||
|
| Composer text color | Plain default foreground, zero SGR around echoed chars. Span `foregroundColor` default (theme fg) is an exact match |
|
||||||
|
| Placeholder | Cycling hint text ("Use /skills...", "Improve documentation in @filename", ...) rendered AT the cursor cell. First prediction lands over placeholder glyphs: covered by the snapshot + cursor-advance rules |
|
||||||
|
| Wrap | Word-wrap near `cols - 2`; continuation rows are indented 2 spaces WITHOUT `› `. The gate therefore suppresses predictions on wrapped lines: deliberate fallback to real echo, wrap was the #220 ghost zone. `edgeMarginCells = 4` |
|
||||||
|
| Modal (trust dialog) | Cursor parks on `" Press enter to continue"`: no `› ` prefix, gate false, zero predictions painted while keystrokes still reach the PTY (the ghost eliminator) |
|
||||||
|
| Streaming | Error/reconnect bursts render above a re-rendered composer that keeps the `› ` signature; end-of-frame cursor parks at the insertion point (col 2 of the composer row). Confirms the cursor-advance confirm rule and the no-drop-on-baseY rule |
|
||||||
|
| Echo shape under tmux | tmux emits minimal deltas for simple echoes and full repaints for busy frames; both converge in the parsed buffer |
|
||||||
|
| Slash picker | Picker rows render below; the cursor row keeps the composer signature and advances per filter char, so predictions stay active while filtering (#222 surface) |
|
||||||
|
|
||||||
|
Constants decided at the Phase 0 gate: `CODEX_COMPOSER_ROW_RE = /^› /`,
|
||||||
|
`ttlMs = 1000`, `maxPending = 32`, `cursorGraceMs = 150`, `edgeMarginCells = 4`,
|
||||||
|
span colors = theme defaults, `underlinePredictions = false`.
|
||||||
|
|
||||||
|
## Algorithm
|
||||||
|
|
||||||
|
See `PredictiveEchoAddon` in
|
||||||
|
`packages/xterm-zerolag-input/src/predictive-echo-addon.ts`. Summary of the
|
||||||
|
rules and why each exists:
|
||||||
|
|
||||||
|
- **State**: ordered `PredictionRecord[]` (`seq`, `char`, `width`, cumulative
|
||||||
|
`offsetCells`, `snapshot` of the cell at predict time, `sentAt`,
|
||||||
|
`mismatches`), plus a run `_anchor {row, col}` captured when the outstanding
|
||||||
|
count goes 0 -> 1. Positions are FIXED at predict time; confirmation deletes
|
||||||
|
spans and never re-lays-out, so partial confirmation causes zero jitter.
|
||||||
|
- **predictChar(ch)** runs an inline reconcile first and re-anchors whenever
|
||||||
|
outstanding drains to zero (absorbs the echo-landed-between-keystrokes race).
|
||||||
|
Guards: dims present, cursor numbers present, `viewportY === baseY`,
|
||||||
|
`predictWhen` gate, single codepoint >= 0x20 (not 0x7f), width <= 2,
|
||||||
|
`maxPending`, edge margin. Returns false = suppressed; the consumer sends the
|
||||||
|
keystroke regardless.
|
||||||
|
- **Coordinate base is `baseY`**: xterm's `cursorY` is baseY-relative, so
|
||||||
|
absolute buffer line = `baseY + row`. `viewportY` would only coincide while
|
||||||
|
the scrolled-to-bottom guards hold; the addon never relies on that.
|
||||||
|
- **reconcile()** (debounced `onWriteParsed` microtask, inline in predictChar,
|
||||||
|
TTL timer): clears everything when scrolled up; off-anchor-row cursor
|
||||||
|
tolerated for `cursorGraceMs` then clears; PREFIX-ONLY confirm loop requiring
|
||||||
|
cell match AND cursor advanced past the record (prevents false confirms
|
||||||
|
against placeholder glyphs and makes identical in-place tmux repaints a
|
||||||
|
no-op); TWO-PASS mismatch rule (a cell that is neither snapshot nor predicted
|
||||||
|
char must persist across two passes before cascading the drop: a half-parsed
|
||||||
|
row on pass N is fully redrawn a few ms later); TTL drop of the stale suffix.
|
||||||
|
- **No drop on baseY change**: codex streams push lines to history while the
|
||||||
|
composer stays viewport-pinned; predictions are row-relative to the pinned
|
||||||
|
composer and remain valid (measured above).
|
||||||
|
- **Anchor hold** (added by the independent post-build review): after any wire
|
||||||
|
input whose cursor effect the display has not shown yet (backspace with
|
||||||
|
nothing outstanding = deleting echoed text, every 'clear'-classified input,
|
||||||
|
an IME/plain-paste 'text' commit, and the bypass send paths), new
|
||||||
|
predictions are suppressed until the next PARSED write. Anchoring on the
|
||||||
|
stale cursor painted ghosts one cell off ("tehh" on backspace-then-retype
|
||||||
|
within RTT), blank-neutral and therefore TTL-lived. Worst case is exactly
|
||||||
|
one unpredicted keystroke: its own echo is a write, which releases the hold.
|
||||||
|
- **predictBackspace()** pops the newest outstanding record (informational
|
||||||
|
return; the consumer forwards `\x7f` unconditionally). Deleting already-echoed
|
||||||
|
text renders at RTT in v1.
|
||||||
|
- **CJK/wide**: 2-cell spans, stacking by cumulative visual width, leading-cell
|
||||||
|
confirm. In Codeman, IME input never reaches the hook (`window.cjkActive`
|
||||||
|
returns from onData first); package support exists for other consumers.
|
||||||
|
|
||||||
|
## Integration map (Codeman)
|
||||||
|
|
||||||
|
- Policy: `_localEchoPolicy` (`'buffer' | 'predict' | 'off'`) computed at the
|
||||||
|
end of `_updateLocalEchoState()`; codex + `localEchoEnabled` -> `'predict'`
|
||||||
|
while `_localEchoEnabled` stays false (every 1.12.2 consumer unchanged).
|
||||||
|
- onData hook sits between the buffer block and Normal Mode, classifies via
|
||||||
|
`classifyPredictInput()` (pure, on `window.CodemanTerminalInput`), never
|
||||||
|
returns, try/catch-wrapped: the wire path below is byte-identical with the
|
||||||
|
predictor active, absent, or throwing.
|
||||||
|
- Composer gate: `isCodexComposerRow()` set via `setPredictWhen()` at
|
||||||
|
construction (the vendor footer stays package-agnostic).
|
||||||
|
- Second vendor bundle `vendor/xterm-predictive-echo.js` (postinstall + build);
|
||||||
|
the zerolag bundle build command is untouched and its output byte-identical.
|
||||||
|
Missing/broken bundle = plain 1.12.2 echo (`typeof PredictiveEchoOverlay ===
|
||||||
|
'undefined'` guard).
|
||||||
|
- Prediction clears on: tab switch, SSE reconnect init, `insertTerminalText`,
|
||||||
|
`clearTerminalInput`, voice send, keyboard-accessory `sendKey`, resize, skin
|
||||||
|
and font changes re-read style via `refreshFont()`.
|
||||||
|
|
||||||
|
## Risk register
|
||||||
|
|
||||||
|
Eliminated structurally: other-mode regression (zero edits to buffer
|
||||||
|
addon/branches, byte-identical existing bundle, policy-matrix + byte-identity
|
||||||
|
tests); bundle breakage (separate bundle, graceful degradation); wire
|
||||||
|
corruption (no-return fall-through + try/catch + byte-identity pins at vm and
|
||||||
|
E2E level); modal ghosts (measured predictWhen gate); false confirms
|
||||||
|
(cursor-advance rule); mid-parse flicker drops (two-pass rule); wrap
|
||||||
|
misplacement (edge margin + continuation-row gate fallback + off-row grace).
|
||||||
|
|
||||||
|
Accepted residuals (visual-only, self-healing <= ttlMs, kill-switchable via
|
||||||
|
`localEchoEnabled` per device): no predictions on wrapped continuation lines
|
||||||
|
(gate false there, deliberate); brief dropout during composer growth; DOM-span
|
||||||
|
vs WebGL glyph rendering can differ subtly (same trade-off as the buffer
|
||||||
|
overlay, same font recipe); typing during an unsynchronized half-frame can
|
||||||
|
mis-anchor one run (mismatch/TTL cleans within 1s).
|
||||||
|
|
||||||
|
## Future work
|
||||||
|
|
||||||
|
RTT-adaptive TTL; mosh-style confidence gating (paint only after the link
|
||||||
|
proves laggy); predicted backspace into echoed text; predict mode for shell
|
||||||
|
prompts; unifying the small font/container duplication between the two addons
|
||||||
|
once predict mode has proven out; continuation-line prediction behind a
|
||||||
|
smarter composer-extent detector.
|
||||||
Generated
+11
@@ -4547,6 +4547,16 @@
|
|||||||
"integrity": "sha512-b3fMOsyLVuCeNJWxolACEUED0vm7qC0cy4wRvf3oURSzDTYVQiGPhTnhWZwIHdvC48Y+oLhvYXnY4XDXPoJo6A==",
|
"integrity": "sha512-b3fMOsyLVuCeNJWxolACEUED0vm7qC0cy4wRvf3oURSzDTYVQiGPhTnhWZwIHdvC48Y+oLhvYXnY4XDXPoJo6A==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@xterm/headless": {
|
||||||
|
"version": "6.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@xterm/headless/-/headless-6.0.0.tgz",
|
||||||
|
"integrity": "sha512-5Yj1QINYCyzrZtf8OFIHi47iQtI+0qYFPHmouEfG8dHNxbZ9Tb9YGSuLcsEwj9Z+OL75GJqPyJbyoFer80a2Hw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"workspaces": [
|
||||||
|
"addons/*"
|
||||||
|
]
|
||||||
|
},
|
||||||
"node_modules/@xterm/xterm": {
|
"node_modules/@xterm/xterm": {
|
||||||
"version": "6.0.0",
|
"version": "6.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz",
|
||||||
@@ -12336,6 +12346,7 @@
|
|||||||
"version": "0.1.8",
|
"version": "0.1.8",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@xterm/headless": "^6.0.0",
|
||||||
"jsdom": "^24.1.3",
|
"jsdom": "^24.1.3",
|
||||||
"tsup": "^8.5.1",
|
"tsup": "^8.5.1",
|
||||||
"typescript": "^5.5.0",
|
"typescript": "^5.5.0",
|
||||||
|
|||||||
@@ -1,5 +1,17 @@
|
|||||||
# xterm-zerolag-input
|
# xterm-zerolag-input
|
||||||
|
|
||||||
|
## 0.2.0
|
||||||
|
|
||||||
|
### Minor Changes
|
||||||
|
|
||||||
|
- **New addon: `PredictiveEchoAddon`, mosh-style write-through prediction.** The second echo mode for per-keystroke TUIs (OpenAI Codex's composer, live pickers) that buffer-until-Enter starves. Every keystroke is sent by the consumer immediately and unchanged; the addon paints the predicted glyph at the predicted cell and reconciles against the PARSED terminal buffer: confirmation requires the cell match plus a cursor advance past the record, foreign non-blank content on two consecutive passes cascades a drop, blank cells are neutral, a TTL bounds everything, and scroll/resize/sustained cursor moves clear the run. Visual-only by construction; it cannot gate, delay or rewrite input.
|
||||||
|
- Anchor-hold rule: after an unpredicted wire edit (backspace into echoed text, cleared input, an IME text commit) new predictions hold until the next parsed write, so a stale displayed cursor can never mis-anchor a run (worst case: exactly one unpredicted keystroke).
|
||||||
|
- New exports: `PredictiveEchoAddon`, `PredictiveEchoOptions`, `PredictionState`, plus the long-intended `charCellWidth` / `stringCellWidth` helpers.
|
||||||
|
- `XtermTerminal` type gains OPTIONAL members (`buffer.active.cursorX/cursorY`, `getLine().getCell?`, `onWriteParsed?`, `onResize?`). Additive only: existing consumers and mocks are unaffected.
|
||||||
|
- IIFE build exposes `window.PredictiveEchoAddon` and a self-activating `window.PredictiveEchoOverlay`, alongside the unchanged `ZerolagInputAddon` / `LocalEchoOverlay` globals.
|
||||||
|
- Tests: 52 new (30 addon-law specs, renderer geometry, 6 replay suites driven by fixtures recorded from real codex 0.147 through tmux + the production strip, and a 500-iteration seeded fuzz with per-op invariants). `@xterm/headless` as a devDependency; runtime dependencies remain zero.
|
||||||
|
|
||||||
|
|
||||||
## 0.1.8
|
## 0.1.8
|
||||||
|
|
||||||
### Patch Changes
|
### Patch Changes
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
<a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/badge/License-MIT-1e3a5f?style=flat-square" alt="MIT"></a>
|
<a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/badge/License-MIT-1e3a5f?style=flat-square" alt="MIT"></a>
|
||||||
<img src="https://img.shields.io/badge/Dependencies-0-22c55e?style=flat-square" alt="Zero dependencies">
|
<img src="https://img.shields.io/badge/Dependencies-0-22c55e?style=flat-square" alt="Zero dependencies">
|
||||||
<img src="https://img.shields.io/badge/Size-6.1%20kB%20gzip-22c55e?style=flat-square" alt="6.1 kB gzipped">
|
<img src="https://img.shields.io/badge/Size-6.1%20kB%20gzip-22c55e?style=flat-square" alt="6.1 kB gzipped">
|
||||||
<img src="https://img.shields.io/badge/Tests-175-22c55e?style=flat-square" alt="175 tests">
|
<img src="https://img.shields.io/badge/Tests-227-22c55e?style=flat-square" alt="175 tests">
|
||||||
<img src="https://img.shields.io/badge/xterm.js-v5%20%7C%20v7+-3b82f6?style=flat-square" alt="xterm.js v5 and v7+">
|
<img src="https://img.shields.io/badge/xterm.js-v5%20%7C%20v7+-3b82f6?style=flat-square" alt="xterm.js v5 and v7+">
|
||||||
</p>
|
</p>
|
||||||
</p>
|
</p>
|
||||||
@@ -46,6 +46,15 @@ Same keystroke, same link. The only difference is who you wait for: the server,
|
|||||||
|
|
||||||
**No backend changes. No protocol. No server support.** It is a client-side addon that never touches the wire.
|
**No backend changes. No protocol. No server support.** It is a client-side addon that never touches the wire.
|
||||||
|
|
||||||
|
Since 0.2.0 the package ships **two addons for two kinds of TUIs**:
|
||||||
|
|
||||||
|
| Addon | Model | Use when |
|
||||||
|
| --------------------- | -------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `ZerolagInputAddon` | **Buffer**: hold keystrokes locally, flush on Enter | The remote side is a line-oriented prompt (shells, REPLs, Claude Code's composer) that only needs the finished line |
|
||||||
|
| `PredictiveEchoAddon` | **Predictive write-through**: send every keystroke immediately, paint a prediction, confirm against the parsed buffer | The remote side is a per-keystroke TUI (OpenAI Codex's composer, live pickers) that buffering would starve |
|
||||||
|
|
||||||
|
`ZerolagInputAddon` is documented below; jump to [PredictiveEchoAddon](#predictiveechoaddon-write-through-prediction) for the second mode.
|
||||||
|
|
||||||
## Why this one
|
## Why this one
|
||||||
|
|
||||||
| | |
|
| | |
|
||||||
@@ -268,6 +277,110 @@ Finds text that exists after the prompt but was never typed through the overlay.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## `PredictiveEchoAddon` (write-through prediction)
|
||||||
|
|
||||||
|
Buffering is the wrong model for TUIs that react to every keystroke: a slash
|
||||||
|
command picker filters live, arrows edit server-side state, the composer
|
||||||
|
rewraps as it grows. For those, `PredictiveEchoAddon` works like
|
||||||
|
[mosh](https://mosh.org/): the keystroke goes to the PTY **immediately and
|
||||||
|
unchanged**, and the addon simultaneously paints the predicted glyph at the
|
||||||
|
predicted cell. When the real echo lands, the prediction is confirmed and its
|
||||||
|
span removed: an invisible swap, identical glyph beneath. Mispredictions
|
||||||
|
self-heal via a mismatch cascade and a TTL. It is visual-only by construction:
|
||||||
|
nothing it does can gate, delay, reorder or rewrite what you send.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { Terminal } from '@xterm/xterm';
|
||||||
|
import { PredictiveEchoAddon } from 'xterm-zerolag-input';
|
||||||
|
|
||||||
|
const terminal = new Terminal();
|
||||||
|
const predictor = new PredictiveEchoAddon({
|
||||||
|
// Optional: only predict when the cursor sits on a composer row
|
||||||
|
predictWhen: (t) => {
|
||||||
|
const buf = t.buffer.active;
|
||||||
|
const line = buf.getLine(buf.baseY + buf.cursorY);
|
||||||
|
return !!line && /^› /.test(line.translateToString(true));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
terminal.loadAddon(predictor);
|
||||||
|
|
||||||
|
terminal.onData((data) => {
|
||||||
|
const cps = Array.from(data);
|
||||||
|
if (cps.length === 1) {
|
||||||
|
const cp = cps[0].codePointAt(0);
|
||||||
|
if (cp === 0x7f) predictor.predictBackspace();
|
||||||
|
else if (cp >= 0x20) predictor.predictChar(data);
|
||||||
|
else predictor.clearPredictions(); // Enter, Ctrl+C, ...
|
||||||
|
} else if (data.charCodeAt(0) === 0x1b) {
|
||||||
|
predictor.clearPredictions(); // nav keys, bracketed paste
|
||||||
|
}
|
||||||
|
pty.write(data); // ALWAYS, unconditionally
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### How reconciliation works
|
||||||
|
|
||||||
|
Predictions are reconciled against the **parsed terminal buffer** (cells after
|
||||||
|
xterm's parser ran), never the raw output stream. That distinction is
|
||||||
|
load-bearing: TUIs redraw whole lines, paint gaps with `ECH` + cursor-forward
|
||||||
|
instead of spaces, and multiplexers like tmux rewrite everything into minimal
|
||||||
|
deltas. Stream matching breaks on all of that; buffer cells converge to the
|
||||||
|
same values no matter how the bytes arrived.
|
||||||
|
|
||||||
|
A prediction is **confirmed** only when its cell shows the predicted glyph AND
|
||||||
|
the cursor has advanced past it (so a placeholder that happens to match, or an
|
||||||
|
identical in-place repaint, never false-confirms). A cell showing foreign
|
||||||
|
non-blank content on two consecutive passes drops that prediction and all
|
||||||
|
later ones (one pass tolerates half-parsed frames). Blank cells are neutral:
|
||||||
|
they are what "not yet echoed" looks like. Whatever remains is dropped by TTL.
|
||||||
|
Scrolling up, resizing, or a sustained cursor move clears the run. After a
|
||||||
|
backspace into already-echoed text, a cleared input, or a multi-char commit,
|
||||||
|
the addon **holds** new predictions until the next parsed write: the displayed
|
||||||
|
cursor is stale for one round trip, and anchoring on it would paint ghosts one
|
||||||
|
cell off (worst case: exactly one unpredicted keystroke, whose own echo
|
||||||
|
releases the hold).
|
||||||
|
|
||||||
|
### API
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
predictChar(ch: string): boolean; // false = suppressed (still SEND the key)
|
||||||
|
predictBackspace(): boolean; // pops the newest prediction (still send \x7f)
|
||||||
|
clearPredictions(): void;
|
||||||
|
reconcile(): void; // manual pass (no onWriteParsed available)
|
||||||
|
setPredictWhen(fn | null): void; // swap the gate at runtime
|
||||||
|
refreshFont(): void; // after font/theme changes
|
||||||
|
get hasPredictions(): boolean;
|
||||||
|
get state(): PredictionState; // { outstanding, confirmedTotal, droppedTotal, anchor }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Options
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
zIndex?: number, // Default: 7
|
||||||
|
underlinePredictions?: boolean, // Default: false (underline unconfirmed glyphs)
|
||||||
|
foregroundColor?: string, // Default: terminal theme / computed .xterm-rows style
|
||||||
|
backgroundColor?: string, // Default: terminal theme background
|
||||||
|
ttlMs?: number, // Default: 1000
|
||||||
|
maxPending?: number, // Default: 32
|
||||||
|
cursorGraceMs?: number, // Default: 150
|
||||||
|
edgeMarginCells?: number, // Default: 4 (suppress near the right edge)
|
||||||
|
predictWhen?: (t) => boolean, // Default: predict everywhere
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Which addon should I use?
|
||||||
|
|
||||||
|
- The remote program shows a **line prompt** and ignores partial input:
|
||||||
|
`ZerolagInputAddon`. You also get backspace-before-send and batching.
|
||||||
|
- The remote program **reacts per keystroke** (pickers, filters, composers
|
||||||
|
that rewrap): `PredictiveEchoAddon`. It never withholds bytes, so the TUI
|
||||||
|
behaves exactly as with no addon at all; you just stop waiting for the RTT.
|
||||||
|
- Both can be loaded on one terminal and toggled per session mode; that is
|
||||||
|
exactly what Codeman does (buffer for Claude Code, predict for Codex).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Integration patterns
|
## Integration patterns
|
||||||
|
|
||||||
### Buffered input (hold until Enter)
|
### Buffered input (hold until Enter)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "xterm-zerolag-input",
|
"name": "xterm-zerolag-input",
|
||||||
"version": "0.1.8",
|
"version": "0.2.0",
|
||||||
"description": "Instant keystroke feedback overlay for xterm.js: Mosh-inspired local echo that removes perceived input latency over SSH, tunnels and other high-RTT connections",
|
"description": "Instant keystroke feedback overlay for xterm.js: Mosh-inspired local echo that removes perceived input latency over SSH, tunnels and other high-RTT connections",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.cjs",
|
"main": "dist/index.cjs",
|
||||||
@@ -37,7 +37,9 @@
|
|||||||
"ssh",
|
"ssh",
|
||||||
"remote-terminal",
|
"remote-terminal",
|
||||||
"overlay",
|
"overlay",
|
||||||
"addon"
|
"addon",
|
||||||
|
"predictive",
|
||||||
|
"write-through"
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"homepage": "https://github.com/Ark0N/Codeman/tree/master/packages/xterm-zerolag-input#readme",
|
"homepage": "https://github.com/Ark0N/Codeman/tree/master/packages/xterm-zerolag-input#readme",
|
||||||
@@ -50,6 +52,7 @@
|
|||||||
"directory": "packages/xterm-zerolag-input"
|
"directory": "packages/xterm-zerolag-input"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@xterm/headless": "^6.0.0",
|
||||||
"jsdom": "^24.1.3",
|
"jsdom": "^24.1.3",
|
||||||
"tsup": "^8.5.1",
|
"tsup": "^8.5.1",
|
||||||
"typescript": "^5.5.0",
|
"typescript": "^5.5.0",
|
||||||
|
|||||||
@@ -11,37 +11,36 @@ import type { XtermTerminal, CellDimensions } from './types.js';
|
|||||||
* unavailable.
|
* unavailable.
|
||||||
*/
|
*/
|
||||||
export function getCellDimensions(terminal: XtermTerminal): CellDimensions | null {
|
export function getCellDimensions(terminal: XtermTerminal): CellDimensions | null {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const t = terminal as any;
|
const t = terminal as any;
|
||||||
const dpr = typeof devicePixelRatio === 'number' && devicePixelRatio > 0
|
const dpr = typeof devicePixelRatio === 'number' && devicePixelRatio > 0 ? devicePixelRatio : 1;
|
||||||
? devicePixelRatio : 1;
|
|
||||||
|
|
||||||
// Try v7+ public API first
|
// Try v7+ public API first
|
||||||
if (t.dimensions?.css?.cell) {
|
if (t.dimensions?.css?.cell) {
|
||||||
const cellH = t.dimensions.css.cell.height;
|
const cellH = t.dimensions.css.cell.height;
|
||||||
return {
|
return {
|
||||||
width: t.dimensions.css.cell.width,
|
width: t.dimensions.css.cell.width,
|
||||||
height: cellH,
|
height: cellH,
|
||||||
charTop: (t.dimensions?.device?.char?.top ?? 0) / dpr,
|
charTop: (t.dimensions?.device?.char?.top ?? 0) / dpr,
|
||||||
charHeight: (t.dimensions?.device?.char?.height ?? (cellH * dpr)) / dpr,
|
charHeight: (t.dimensions?.device?.char?.height ?? cellH * dpr) / dpr,
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to v5 private API
|
||||||
|
try {
|
||||||
|
const dims = t._core?._renderService?.dimensions;
|
||||||
|
if (dims?.css?.cell) {
|
||||||
|
const cellH = dims.css.cell.height;
|
||||||
|
return {
|
||||||
|
width: dims.css.cell.width,
|
||||||
|
height: cellH,
|
||||||
|
charTop: (dims.device?.char?.top ?? 0) / dpr,
|
||||||
|
charHeight: (dims.device?.char?.height ?? cellH * dpr) / dpr,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
} catch {
|
||||||
|
// Private API may throw in some environments
|
||||||
|
}
|
||||||
|
|
||||||
// Fall back to v5 private API
|
return null;
|
||||||
try {
|
|
||||||
const dims = t._core?._renderService?.dimensions;
|
|
||||||
if (dims?.css?.cell) {
|
|
||||||
const cellH = dims.css.cell.height;
|
|
||||||
return {
|
|
||||||
width: dims.css.cell.width,
|
|
||||||
height: cellH,
|
|
||||||
charTop: (dims.device?.char?.top ?? 0) / dpr,
|
|
||||||
charHeight: (dims.device?.char?.height ?? (cellH * dpr)) / dpr,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Private API may throw in some environments
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
export { ZerolagInputAddon } from './zerolag-input-addon.js';
|
export { ZerolagInputAddon } from './zerolag-input-addon.js';
|
||||||
|
export { PredictiveEchoAddon } from './predictive-echo-addon.js';
|
||||||
|
export { charCellWidth, stringCellWidth } from './overlay-renderer.js';
|
||||||
export type {
|
export type {
|
||||||
XtermTerminal,
|
XtermTerminal,
|
||||||
XtermAddon,
|
XtermAddon,
|
||||||
ZerolagInputOptions,
|
ZerolagInputOptions,
|
||||||
ZerolagInputState,
|
ZerolagInputState,
|
||||||
PromptFinder,
|
PromptFinder,
|
||||||
PromptPosition,
|
PromptPosition,
|
||||||
CellDimensions,
|
CellDimensions,
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
|
export type { PredictiveEchoOptions, PredictionState } from './predictive-echo-addon.js';
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
/**
|
||||||
|
* Incremental DOM renderer for PredictiveEchoAddon.
|
||||||
|
*
|
||||||
|
* Unlike overlay-renderer.ts (which paints whole lines with an opaque
|
||||||
|
* background out to totalCols), prediction spans cover ONLY the predicted
|
||||||
|
* glyph's own cells: anything wider would blank real echo arriving around
|
||||||
|
* a prediction. Spans are keyed by prediction seq for O(1) removal.
|
||||||
|
*/
|
||||||
|
import type { CellDimensions, FontStyle } from './types.js';
|
||||||
|
|
||||||
|
export interface PredictionSpanParams {
|
||||||
|
seq: number;
|
||||||
|
/** Viewport-relative row (0-based). */
|
||||||
|
row: number;
|
||||||
|
/** Column (0-based). */
|
||||||
|
col: number;
|
||||||
|
char: string;
|
||||||
|
/** Cell width of the glyph (1 or 2). */
|
||||||
|
width: 1 | 2;
|
||||||
|
dims: CellDimensions;
|
||||||
|
font: FontStyle;
|
||||||
|
underline: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addPredictionSpan(
|
||||||
|
container: HTMLElement,
|
||||||
|
map: Map<number, HTMLSpanElement>,
|
||||||
|
p: PredictionSpanParams
|
||||||
|
): void {
|
||||||
|
const span = document.createElement('span');
|
||||||
|
// cellH+1 height: covers the sub-pixel seam between rows (same trick the
|
||||||
|
// buffer overlay renderer ships with). Background covers only this glyph's
|
||||||
|
// cells, never a full row.
|
||||||
|
span.style.cssText =
|
||||||
|
`position:absolute;left:${p.col * p.dims.width}px;top:${p.row * p.dims.height}px;` +
|
||||||
|
`width:${p.width * p.dims.width}px;height:${p.dims.height + 1}px;line-height:${p.dims.height}px;` +
|
||||||
|
`text-align:center;pointer-events:none;` +
|
||||||
|
`font-family:${p.font.fontFamily};font-size:${p.font.fontSize};font-weight:${p.font.fontWeight};` +
|
||||||
|
(p.font.letterSpacing ? `letter-spacing:${p.font.letterSpacing};` : '') +
|
||||||
|
`color:${p.font.color};background-color:${p.font.backgroundColor};` +
|
||||||
|
`font-feature-settings:'liga' 0,'calt' 0;` +
|
||||||
|
(p.underline ? 'text-decoration:underline;' : '');
|
||||||
|
span.textContent = p.char;
|
||||||
|
map.set(p.seq, span);
|
||||||
|
container.appendChild(span);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removePredictionSpan(map: Map<number, HTMLSpanElement>, seq: number): void {
|
||||||
|
const span = map.get(seq);
|
||||||
|
if (span) {
|
||||||
|
span.remove();
|
||||||
|
map.delete(seq);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearAllSpans(map: Map<number, HTMLSpanElement>): void {
|
||||||
|
for (const span of map.values()) span.remove();
|
||||||
|
map.clear();
|
||||||
|
}
|
||||||
@@ -0,0 +1,480 @@
|
|||||||
|
/**
|
||||||
|
* PredictiveEchoAddon: mosh-style write-through local echo.
|
||||||
|
*
|
||||||
|
* The consumer sends every keystroke to the PTY unchanged (write-through);
|
||||||
|
* this addon simultaneously paints the predicted glyph at the predicted cell.
|
||||||
|
* When the real echo lands, the prediction is confirmed and its span removed
|
||||||
|
* (an invisible swap: identical glyph beneath). Mispredictions self-heal via
|
||||||
|
* a mismatch cascade and a TTL. Everything here is visual-only: no method
|
||||||
|
* gates, delays, or rewrites what the consumer sends.
|
||||||
|
*
|
||||||
|
* Reconciliation reads the parsed terminal BUFFER (cells after xterm's parser
|
||||||
|
* ran), never the raw output stream. Full-line redraws, ECH-based gap
|
||||||
|
* painting, and tmux's in-place deltas all converge to the same cells; stream
|
||||||
|
* matching cannot survive them (see docs/local-echo-overlay-plan.md's
|
||||||
|
* "What NOT to Do" in the consuming repo).
|
||||||
|
*
|
||||||
|
* Coordinate base: xterm's `cursorY` is relative to `baseY`, so the absolute
|
||||||
|
* buffer line for a viewport row is `baseY + row`. `viewportY` would only
|
||||||
|
* coincide while scrolled to the bottom; this file never relies on that.
|
||||||
|
*/
|
||||||
|
import { getCellDimensions } from './cell-dimensions.js';
|
||||||
|
import { charCellWidth } from './overlay-renderer.js';
|
||||||
|
import { addPredictionSpan, clearAllSpans, removePredictionSpan } from './prediction-renderer.js';
|
||||||
|
import type { FontStyle, XtermAddon, XtermTerminal } from './types.js';
|
||||||
|
|
||||||
|
export interface PredictiveEchoOptions {
|
||||||
|
/** Z-index of the span container. @default 7 (same layer as the buffer overlay) */
|
||||||
|
zIndex?: number;
|
||||||
|
/** Render predicted glyphs underlined (visual hedge on unreliable links). @default false */
|
||||||
|
underlinePredictions?: boolean;
|
||||||
|
/** Predicted glyph color. @default theme foreground / computed .xterm-rows color */
|
||||||
|
foregroundColor?: string;
|
||||||
|
/** Predicted glyph background. @default theme background */
|
||||||
|
backgroundColor?: string;
|
||||||
|
/** Drop predictions older than this. @default 1000 */
|
||||||
|
ttlMs?: number;
|
||||||
|
/** Maximum outstanding predictions per run. @default 32 */
|
||||||
|
maxPending?: number;
|
||||||
|
/** How long the cursor may sit off the anchor row before predictions clear. @default 150 */
|
||||||
|
cursorGraceMs?: number;
|
||||||
|
/** Suppress predictions that would land within this many cells of the right edge. @default 4 */
|
||||||
|
edgeMarginCells?: number;
|
||||||
|
/** Gate: return false to suppress prediction (e.g. cursor not on a composer row). */
|
||||||
|
predictWhen?: (terminal: XtermTerminal) => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PredictionState {
|
||||||
|
outstanding: number;
|
||||||
|
confirmedTotal: number;
|
||||||
|
droppedTotal: number;
|
||||||
|
anchor: { row: number; col: number } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PredictionRecord {
|
||||||
|
seq: number;
|
||||||
|
char: string;
|
||||||
|
/** Cells this glyph occupies. */
|
||||||
|
width: 1 | 2;
|
||||||
|
/** Cumulative cell offset from the anchor column BEFORE this char. */
|
||||||
|
offsetCells: number;
|
||||||
|
/** Cell content at predict time, '' normalized to ' '. */
|
||||||
|
snapshot: string;
|
||||||
|
sentAt: number;
|
||||||
|
/** Consecutive reconcile passes that saw foreign non-blank content. */
|
||||||
|
mismatches: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_OPTIONS = {
|
||||||
|
zIndex: 7,
|
||||||
|
underlinePredictions: false,
|
||||||
|
ttlMs: 1000,
|
||||||
|
maxPending: 32,
|
||||||
|
cursorGraceMs: 150,
|
||||||
|
edgeMarginCells: 4,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const DEFAULT_BG = '#000000';
|
||||||
|
const DEFAULT_FG = '#ffffff';
|
||||||
|
|
||||||
|
export class PredictiveEchoAddon implements XtermAddon {
|
||||||
|
private _terminal: XtermTerminal | null = null;
|
||||||
|
private _container: HTMLDivElement | null = null;
|
||||||
|
private _spans = new Map<number, HTMLSpanElement>();
|
||||||
|
private _outstanding: PredictionRecord[] = [];
|
||||||
|
private _anchor: { row: number; col: number } | null = null;
|
||||||
|
private _cursorOffRowSince: number | null = null;
|
||||||
|
private _seq = 0;
|
||||||
|
private _confirmedTotal = 0;
|
||||||
|
private _droppedTotal = 0;
|
||||||
|
private _ttlTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
/** Anchor hold: set after an unpredicted wire edit (backspace into echoed
|
||||||
|
* text, any cleared input, an IME text commit). While held, new
|
||||||
|
* predictions are suppressed: the displayed cursor is stale until the
|
||||||
|
* next parsed write, and anchoring on it paints ghosts one cell off
|
||||||
|
* (found by review: backspace-then-retype within RTT). Cleared by the
|
||||||
|
* onWriteParsed pass and by public reconcile(), never by the inline
|
||||||
|
* predictChar pass (which runs before the display could catch up). */
|
||||||
|
private _anchorHold = false;
|
||||||
|
private _reconcileScheduled = false;
|
||||||
|
private _disposables: Array<{ dispose(): void }> = [];
|
||||||
|
private _predictWhen: ((terminal: XtermTerminal) => boolean) | null;
|
||||||
|
private _options: Required<Omit<PredictiveEchoOptions, 'foregroundColor' | 'backgroundColor' | 'predictWhen'>> &
|
||||||
|
Pick<PredictiveEchoOptions, 'foregroundColor' | 'backgroundColor'>;
|
||||||
|
private _font: FontStyle = {
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 'normal',
|
||||||
|
color: DEFAULT_FG,
|
||||||
|
backgroundColor: DEFAULT_BG,
|
||||||
|
letterSpacing: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
constructor(options?: PredictiveEchoOptions) {
|
||||||
|
this._options = {
|
||||||
|
zIndex: options?.zIndex ?? DEFAULT_OPTIONS.zIndex,
|
||||||
|
underlinePredictions: options?.underlinePredictions ?? DEFAULT_OPTIONS.underlinePredictions,
|
||||||
|
ttlMs: options?.ttlMs ?? DEFAULT_OPTIONS.ttlMs,
|
||||||
|
maxPending: options?.maxPending ?? DEFAULT_OPTIONS.maxPending,
|
||||||
|
cursorGraceMs: options?.cursorGraceMs ?? DEFAULT_OPTIONS.cursorGraceMs,
|
||||||
|
edgeMarginCells: options?.edgeMarginCells ?? DEFAULT_OPTIONS.edgeMarginCells,
|
||||||
|
foregroundColor: options?.foregroundColor,
|
||||||
|
backgroundColor: options?.backgroundColor,
|
||||||
|
};
|
||||||
|
this._predictWhen = options?.predictWhen ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Lifecycle ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Called by `terminal.loadAddon()`. Do not call directly. */
|
||||||
|
activate(terminal: XtermTerminal): void {
|
||||||
|
this._terminal = terminal;
|
||||||
|
|
||||||
|
this._container = document.createElement('div');
|
||||||
|
this._container.setAttribute('data-predictive-echo', '');
|
||||||
|
this._container.style.cssText = `position:absolute;left:0;top:0;z-index:${this._options.zIndex};pointer-events:none`;
|
||||||
|
const screen = terminal.element?.querySelector('.xterm-screen');
|
||||||
|
if (screen) screen.appendChild(this._container);
|
||||||
|
|
||||||
|
this._readFontStyle();
|
||||||
|
|
||||||
|
// Debounced post-parse reconcile: xterm fires onWriteParsed after the
|
||||||
|
// parser finishes a write chunk, so buffer reads see consistent state.
|
||||||
|
// The microtask coalesces multi-chunk bursts into one pass.
|
||||||
|
if (typeof terminal.onWriteParsed === 'function') {
|
||||||
|
try {
|
||||||
|
this._disposables.push(
|
||||||
|
terminal.onWriteParsed(() => {
|
||||||
|
if (this._reconcileScheduled) return;
|
||||||
|
this._reconcileScheduled = true;
|
||||||
|
queueMicrotask(() => {
|
||||||
|
this._reconcileScheduled = false;
|
||||||
|
this._anchorHold = false; // a parse pass ran: the display caught up
|
||||||
|
this._safeReconcile();
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
/* consumers without a working emitter fall back to manual reconcile() */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (typeof terminal.onResize === 'function') {
|
||||||
|
try {
|
||||||
|
this._disposables.push(terminal.onResize(() => this.clearPredictions()));
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose(): void {
|
||||||
|
this.clearPredictions();
|
||||||
|
for (const d of this._disposables) {
|
||||||
|
try {
|
||||||
|
d.dispose();
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this._disposables = [];
|
||||||
|
this._container?.remove();
|
||||||
|
this._container = null;
|
||||||
|
this._terminal = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Public API ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Predict a single typed character at the current insertion point.
|
||||||
|
* Returns false when suppressed; the consumer sends the keystroke to the
|
||||||
|
* PTY either way (the return value is informational, never a send gate).
|
||||||
|
*/
|
||||||
|
predictChar(ch: string): boolean {
|
||||||
|
try {
|
||||||
|
this._reconcile();
|
||||||
|
if (this._anchorHold) return false; // display has not caught up with a wire edit
|
||||||
|
|
||||||
|
const t = this._terminal;
|
||||||
|
if (!t || !this._container) return false;
|
||||||
|
const dims = getCellDimensions(t);
|
||||||
|
if (!dims) return false;
|
||||||
|
const buf = t.buffer.active;
|
||||||
|
if (typeof buf.cursorX !== 'number' || typeof buf.cursorY !== 'number') return false;
|
||||||
|
if (buf.viewportY !== buf.baseY) return false;
|
||||||
|
if (this._predictWhen && this._predictWhen(t) === false) return false;
|
||||||
|
|
||||||
|
const cps = Array.from(ch);
|
||||||
|
if (cps.length !== 1) return false;
|
||||||
|
const cp = cps[0].codePointAt(0)!;
|
||||||
|
if (cp < 0x20 || cp === 0x7f) return false;
|
||||||
|
const w = charCellWidth(t, cps[0]);
|
||||||
|
if (w !== 1 && w !== 2) return false;
|
||||||
|
if (w === 2 && !this._hasGetCell()) return false; // ASCII fallback misaligns on wide cols
|
||||||
|
if (this._outstanding.length >= this._options.maxPending) return false;
|
||||||
|
|
||||||
|
if (this._outstanding.length === 0) {
|
||||||
|
this._anchor = { row: buf.cursorY, col: buf.cursorX };
|
||||||
|
this._cursorOffRowSince = null;
|
||||||
|
}
|
||||||
|
const anchor = this._anchor!;
|
||||||
|
const last = this._outstanding[this._outstanding.length - 1];
|
||||||
|
const offset = last ? last.offsetCells + last.width : 0;
|
||||||
|
const col = anchor.col + offset;
|
||||||
|
if (col + w > t.cols - this._options.edgeMarginCells) return false;
|
||||||
|
|
||||||
|
const rec: PredictionRecord = {
|
||||||
|
seq: this._seq++,
|
||||||
|
char: cps[0],
|
||||||
|
width: w,
|
||||||
|
offsetCells: offset,
|
||||||
|
snapshot: this._readCell(anchor.row, col),
|
||||||
|
sentAt: performance.now(),
|
||||||
|
mismatches: 0,
|
||||||
|
};
|
||||||
|
this._outstanding.push(rec);
|
||||||
|
addPredictionSpan(this._container, this._spans, {
|
||||||
|
seq: rec.seq,
|
||||||
|
row: anchor.row,
|
||||||
|
col,
|
||||||
|
char: rec.char,
|
||||||
|
width: w,
|
||||||
|
dims,
|
||||||
|
font: this._font,
|
||||||
|
underline: this._options.underlinePredictions,
|
||||||
|
});
|
||||||
|
this._armTtl();
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pop the newest outstanding prediction (visual only). Returns false when
|
||||||
|
* none are outstanding. The consumer forwards \x7f UNCONDITIONALLY either
|
||||||
|
* way; deleting already-echoed text renders at RTT.
|
||||||
|
*/
|
||||||
|
predictBackspace(): boolean {
|
||||||
|
try {
|
||||||
|
const rec = this._outstanding.pop();
|
||||||
|
if (!rec) {
|
||||||
|
// \x7f goes to the wire and will delete ECHOED text: the cursor is
|
||||||
|
// about to move in a way we cannot see yet
|
||||||
|
this._anchorHold = true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
removePredictionSpan(this._spans, rec.seq);
|
||||||
|
if (this._outstanding.length === 0) this._resetRun();
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drop every outstanding prediction and its spans. Also arms the anchor
|
||||||
|
* hold: consumers clear on inputs (Enter, Esc, arrows, pastes) whose
|
||||||
|
* cursor effect is unknown until the next parsed write. */
|
||||||
|
clearPredictions(): void {
|
||||||
|
try {
|
||||||
|
this._anchorHold = true;
|
||||||
|
this._droppedTotal += this._outstanding.length;
|
||||||
|
this._outstanding = [];
|
||||||
|
clearAllSpans(this._spans);
|
||||||
|
this._resetRun();
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Manual reconcile pass, for consumers without onWriteParsed. By contract
|
||||||
|
* it is called after writes parsed, so it also releases the anchor hold. */
|
||||||
|
reconcile(): void {
|
||||||
|
this._anchorHold = false;
|
||||||
|
this._safeReconcile();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Swap the prediction gate at runtime (mirrors the buffer addon's setPrompt). */
|
||||||
|
setPredictWhen(fn: ((terminal: XtermTerminal) => boolean) | null): void {
|
||||||
|
this._predictWhen = fn;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Re-read font/theme (call after skin or font-size changes). */
|
||||||
|
refreshFont(): void {
|
||||||
|
this._readFontStyle();
|
||||||
|
}
|
||||||
|
|
||||||
|
get hasPredictions(): boolean {
|
||||||
|
return this._outstanding.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
get state(): PredictionState {
|
||||||
|
return {
|
||||||
|
outstanding: this._outstanding.length,
|
||||||
|
confirmedTotal: this._confirmedTotal,
|
||||||
|
droppedTotal: this._droppedTotal,
|
||||||
|
anchor: this._anchor ? { ...this._anchor } : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Reconciliation ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
private _safeReconcile(): void {
|
||||||
|
try {
|
||||||
|
this._reconcile();
|
||||||
|
} catch {
|
||||||
|
/* predictions may degrade, never break input */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private _reconcile(): void {
|
||||||
|
const t = this._terminal;
|
||||||
|
if (!t) return;
|
||||||
|
if (this._outstanding.length === 0) return; // streaming cost: one boolean
|
||||||
|
const buf = t.buffer.active;
|
||||||
|
if (buf.viewportY !== buf.baseY) {
|
||||||
|
this.clearPredictions(); // user scrolled up
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (typeof buf.cursorX !== 'number' || typeof buf.cursorY !== 'number') return; // TTL will clean
|
||||||
|
const anchor = this._anchor!;
|
||||||
|
const now = performance.now();
|
||||||
|
|
||||||
|
// Off-row grace: transient cursor excursions (repaints park the cursor
|
||||||
|
// elsewhere mid-frame) are tolerated; a sustained move means the composer
|
||||||
|
// relocated or the user navigated, so predictions are stale.
|
||||||
|
if (buf.cursorY !== anchor.row) {
|
||||||
|
this._cursorOffRowSince ??= now;
|
||||||
|
if (now - this._cursorOffRowSince > this._options.cursorGraceMs) {
|
||||||
|
this.clearPredictions();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this._cursorOffRowSince = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Confirm loop: PREFIX-ONLY, and only with the cursor advanced past the
|
||||||
|
// record. Cell match alone is not enough: the predicted char may equal
|
||||||
|
// pre-existing content (placeholder glyphs), and an identical in-place
|
||||||
|
// tmux repaint must be a no-op (cells match snapshots, cursor unmoved).
|
||||||
|
while (this._outstanding.length > 0) {
|
||||||
|
const rec = this._outstanding[0];
|
||||||
|
const cell = this._readCell(anchor.row, anchor.col + rec.offsetCells);
|
||||||
|
if (cell === rec.char && buf.cursorY === anchor.row && buf.cursorX >= anchor.col + rec.offsetCells + rec.width) {
|
||||||
|
this._outstanding.shift();
|
||||||
|
removePredictionSpan(this._spans, rec.seq);
|
||||||
|
this._confirmedTotal++;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mismatch scan (two-pass rule): a half-parsed row on pass N is fully
|
||||||
|
// redrawn a few ms later, so only content foreign on TWO consecutive
|
||||||
|
// passes cascades. Blank cells are NEUTRAL, not foreign: codex clears its
|
||||||
|
// placeholder on the first echo, and the blanks left under later
|
||||||
|
// predictions are what "not yet echoed" looks like, not evidence of a
|
||||||
|
// redraw (measured 2026-08-09; without this, fast typing over the
|
||||||
|
// placeholder cascades exactly when RTT is high). TTL still bounds them.
|
||||||
|
let dropFrom = -1;
|
||||||
|
for (let i = 0; i < this._outstanding.length; i++) {
|
||||||
|
const rec = this._outstanding[i];
|
||||||
|
const cell = this._readCell(anchor.row, anchor.col + rec.offsetCells);
|
||||||
|
if (cell !== rec.snapshot && cell !== rec.char && cell !== ' ') {
|
||||||
|
rec.mismatches++;
|
||||||
|
if (rec.mismatches >= 2) {
|
||||||
|
dropFrom = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
rec.mismatches = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (dropFrom !== -1) this._dropFrom(dropFrom);
|
||||||
|
|
||||||
|
// TTL: the first stale record drops itself and everything after it.
|
||||||
|
for (let i = 0; i < this._outstanding.length; i++) {
|
||||||
|
if (now - this._outstanding[i].sentAt > this._options.ttlMs) {
|
||||||
|
this._dropFrom(i);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this._outstanding.length === 0) {
|
||||||
|
this._resetRun();
|
||||||
|
} else {
|
||||||
|
this._armTtl();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private _dropFrom(index: number): void {
|
||||||
|
const dropped = this._outstanding.splice(index);
|
||||||
|
for (const rec of dropped) removePredictionSpan(this._spans, rec.seq);
|
||||||
|
this._droppedTotal += dropped.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
private _resetRun(): void {
|
||||||
|
this._anchor = null;
|
||||||
|
this._cursorOffRowSince = null;
|
||||||
|
if (this._ttlTimer !== null) {
|
||||||
|
clearTimeout(this._ttlTimer);
|
||||||
|
this._ttlTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private _armTtl(): void {
|
||||||
|
if (this._ttlTimer !== null) return;
|
||||||
|
const oldest = this._outstanding[0];
|
||||||
|
if (!oldest) return;
|
||||||
|
const delay = Math.max(0, oldest.sentAt + this._options.ttlMs - performance.now()) + 1;
|
||||||
|
this._ttlTimer = setTimeout(() => {
|
||||||
|
this._ttlTimer = null;
|
||||||
|
this._safeReconcile();
|
||||||
|
this._armTtl();
|
||||||
|
}, delay);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Cell access ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private _hasGetCell(): boolean {
|
||||||
|
const buf = this._terminal?.buffer.active;
|
||||||
|
if (!buf) return false;
|
||||||
|
const line = buf.getLine(buf.baseY + (buf.cursorY ?? 0));
|
||||||
|
return typeof line?.getCell === 'function';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read one cell's chars at (viewport-relative row, col); '' -> ' '. */
|
||||||
|
private _readCell(row: number, col: number): string {
|
||||||
|
const buf = this._terminal!.buffer.active;
|
||||||
|
const line = buf.getLine(buf.baseY + row);
|
||||||
|
if (!line) return ' ';
|
||||||
|
if (typeof line.getCell === 'function') {
|
||||||
|
const chars = line.getCell(col)?.getChars() ?? '';
|
||||||
|
return chars === '' ? ' ' : chars;
|
||||||
|
}
|
||||||
|
// ASCII fallback: code-unit index, misaligns after wide columns, which is
|
||||||
|
// why width-2 predictions are suppressed without getCell.
|
||||||
|
const text = line.translateToString(true);
|
||||||
|
return text[col] ?? ' ';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Font ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Same recipe as the buffer addon's _cacheFont (kept private on purpose:
|
||||||
|
* zerolag-input-addon.ts must stay untouched by this feature). */
|
||||||
|
private _readFontStyle(): void {
|
||||||
|
const t = this._terminal;
|
||||||
|
if (!t) return;
|
||||||
|
this._font.fontFamily = t.options.fontFamily || 'monospace';
|
||||||
|
this._font.fontSize = (t.options.fontSize || 14) + 'px';
|
||||||
|
this._font.fontWeight = String(t.options.fontWeight || 'normal');
|
||||||
|
this._font.backgroundColor = this._options.backgroundColor ?? t.options.theme?.background ?? DEFAULT_BG;
|
||||||
|
this._font.color = this._options.foregroundColor ?? t.options.theme?.foreground ?? DEFAULT_FG;
|
||||||
|
this._font.letterSpacing = '';
|
||||||
|
const rows = t.element?.querySelector('.xterm-rows');
|
||||||
|
if (rows) {
|
||||||
|
const cs = getComputedStyle(rows);
|
||||||
|
this._font.letterSpacing = cs.letterSpacing;
|
||||||
|
if (!this._options.foregroundColor && cs.color) this._font.color = cs.color;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,55 +6,50 @@ import type { XtermTerminal, PromptFinder, PromptPosition } from './types.js';
|
|||||||
*
|
*
|
||||||
* @returns The prompt position (viewport-relative), or `null` if not found.
|
* @returns The prompt position (viewport-relative), or `null` if not found.
|
||||||
*/
|
*/
|
||||||
export function findPrompt(
|
export function findPrompt(terminal: XtermTerminal, finder: PromptFinder): PromptPosition | null {
|
||||||
terminal: XtermTerminal,
|
try {
|
||||||
finder: PromptFinder,
|
const buffer = terminal.buffer.active;
|
||||||
): PromptPosition | null {
|
const viewportTop = buffer.viewportY;
|
||||||
try {
|
|
||||||
const buffer = terminal.buffer.active;
|
|
||||||
const viewportTop = buffer.viewportY;
|
|
||||||
|
|
||||||
switch (finder.type) {
|
switch (finder.type) {
|
||||||
case 'character': {
|
case 'character': {
|
||||||
for (let row = terminal.rows - 1; row >= 0; row--) {
|
for (let row = terminal.rows - 1; row >= 0; row--) {
|
||||||
const line = buffer.getLine(viewportTop + row);
|
const line = buffer.getLine(viewportTop + row);
|
||||||
if (!line) continue;
|
if (!line) continue;
|
||||||
const text = line.translateToString(true);
|
const text = line.translateToString(true);
|
||||||
const idx = text.lastIndexOf(finder.char);
|
const idx = text.lastIndexOf(finder.char);
|
||||||
if (idx >= 0) return { row, col: idx };
|
if (idx >= 0) return { row, col: idx };
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'regex': {
|
|
||||||
// Create a fresh non-global regex to avoid lastIndex mutation
|
|
||||||
// and ensure .match() returns a single result with .index
|
|
||||||
const pattern = finder.pattern;
|
|
||||||
const safePattern = pattern.global
|
|
||||||
? new RegExp(pattern.source, pattern.flags.replace('g', ''))
|
|
||||||
: pattern;
|
|
||||||
for (let row = terminal.rows - 1; row >= 0; row--) {
|
|
||||||
const line = buffer.getLine(viewportTop + row);
|
|
||||||
if (!line) continue;
|
|
||||||
const text = line.translateToString(true);
|
|
||||||
const match = text.match(safePattern);
|
|
||||||
if (match) {
|
|
||||||
const col = match.index ?? 0;
|
|
||||||
return { row, col };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'custom':
|
|
||||||
return finder.find(terminal);
|
|
||||||
|
|
||||||
default:
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
} catch {
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'regex': {
|
||||||
|
// Create a fresh non-global regex to avoid lastIndex mutation
|
||||||
|
// and ensure .match() returns a single result with .index
|
||||||
|
const pattern = finder.pattern;
|
||||||
|
const safePattern = pattern.global ? new RegExp(pattern.source, pattern.flags.replace('g', '')) : pattern;
|
||||||
|
for (let row = terminal.rows - 1; row >= 0; row--) {
|
||||||
|
const line = buffer.getLine(viewportTop + row);
|
||||||
|
if (!line) continue;
|
||||||
|
const text = line.translateToString(true);
|
||||||
|
const match = text.match(safePattern);
|
||||||
|
if (match) {
|
||||||
|
const col = match.index ?? 0;
|
||||||
|
return { row, col };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'custom':
|
||||||
|
return finder.find(terminal);
|
||||||
|
|
||||||
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -65,19 +60,15 @@ export function findPrompt(
|
|||||||
* @param offset - Characters to skip after the prompt marker (e.g., 2 for "> ")
|
* @param offset - Characters to skip after the prompt marker (e.g., 2 for "> ")
|
||||||
* @returns The text after the prompt, trimmed. Empty string if nothing found.
|
* @returns The text after the prompt, trimmed. Empty string if nothing found.
|
||||||
*/
|
*/
|
||||||
export function readTextAfterPrompt(
|
export function readTextAfterPrompt(terminal: XtermTerminal, prompt: PromptPosition, offset: number): string {
|
||||||
terminal: XtermTerminal,
|
try {
|
||||||
prompt: PromptPosition,
|
const buffer = terminal.buffer.active;
|
||||||
offset: number,
|
const absRow = buffer.viewportY + prompt.row;
|
||||||
): string {
|
const line = buffer.getLine(absRow);
|
||||||
try {
|
if (!line) return '';
|
||||||
const buffer = terminal.buffer.active;
|
const lineText = line.translateToString(true);
|
||||||
const absRow = buffer.viewportY + prompt.row;
|
return lineText.slice(prompt.col + offset).trimEnd();
|
||||||
const line = buffer.getLine(absRow);
|
} catch {
|
||||||
if (!line) return '';
|
return '';
|
||||||
const lineText = line.translateToString(true);
|
}
|
||||||
return lineText.slice(prompt.col + offset).trimEnd();
|
|
||||||
} catch {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,9 +22,15 @@ export interface XtermTerminal {
|
|||||||
readonly active: {
|
readonly active: {
|
||||||
readonly viewportY: number;
|
readonly viewportY: number;
|
||||||
readonly baseY: number;
|
readonly baseY: number;
|
||||||
|
/** Cursor column (0-based). Used by PredictiveEchoAddon. */
|
||||||
|
readonly cursorX?: number;
|
||||||
|
/** Cursor row, relative to baseY (0-based). Used by PredictiveEchoAddon. */
|
||||||
|
readonly cursorY?: number;
|
||||||
getLine(y: number):
|
getLine(y: number):
|
||||||
| {
|
| {
|
||||||
translateToString(trimRight?: boolean): string;
|
translateToString(trimRight?: boolean): string;
|
||||||
|
/** Cell access (xterm public API). Optional: mocks/exotic hosts may omit it. */
|
||||||
|
getCell?(x: number): { getChars(): string; getWidth(): number } | undefined;
|
||||||
}
|
}
|
||||||
| undefined;
|
| undefined;
|
||||||
};
|
};
|
||||||
@@ -34,6 +40,10 @@ export interface XtermTerminal {
|
|||||||
getStringCellWidth(str: string): number;
|
getStringCellWidth(str: string): number;
|
||||||
activeVersion?: string;
|
activeVersion?: string;
|
||||||
};
|
};
|
||||||
|
/** Fires after the parser finishes a write chunk. Used by PredictiveEchoAddon. */
|
||||||
|
onWriteParsed?(cb: () => void): { dispose(): void };
|
||||||
|
/** Fires on terminal resize. Used by PredictiveEchoAddon. */
|
||||||
|
onResize?(cb: (size: { cols: number; rows: number }) => void): { dispose(): void };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -6,122 +6,125 @@ import type { XtermTerminal } from '../src/types.js';
|
|||||||
let cleanups: (() => void)[] = [];
|
let cleanups: (() => void)[] = [];
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
for (const fn of cleanups) fn();
|
for (const fn of cleanups) fn();
|
||||||
cleanups = [];
|
cleanups = [];
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('getCellDimensions', () => {
|
describe('getCellDimensions', () => {
|
||||||
describe('v5 private API (mock _core._renderService)', () => {
|
describe('v5 private API (mock _core._renderService)', () => {
|
||||||
it('returns cell width and height from css.cell', () => {
|
it('returns cell width and height from css.cell', () => {
|
||||||
const mock = createMockTerminal({ cellWidth: 8.4, cellHeight: 19 });
|
const mock = createMockTerminal({ cellWidth: 8.4, cellHeight: 19 });
|
||||||
cleanups.push(mock.cleanup);
|
cleanups.push(mock.cleanup);
|
||||||
const dims = getCellDimensions(mock.terminal as unknown as XtermTerminal);
|
const dims = getCellDimensions(mock.terminal as unknown as XtermTerminal);
|
||||||
expect(dims).not.toBeNull();
|
expect(dims).not.toBeNull();
|
||||||
expect(dims!.width).toBe(8.4);
|
expect(dims!.width).toBe(8.4);
|
||||||
expect(dims!.height).toBe(19);
|
expect(dims!.height).toBe(19);
|
||||||
});
|
|
||||||
|
|
||||||
it('returns charTop from device.char.top divided by DPR', () => {
|
|
||||||
const mock = createMockTerminal({
|
|
||||||
cellWidth: 8, cellHeight: 19,
|
|
||||||
deviceCharTop: 2,
|
|
||||||
});
|
|
||||||
cleanups.push(mock.cleanup);
|
|
||||||
const dims = getCellDimensions(mock.terminal as unknown as XtermTerminal);
|
|
||||||
expect(dims).not.toBeNull();
|
|
||||||
// DPR=1 in jsdom, so charTop = 2 / 1 = 2
|
|
||||||
expect(dims!.charTop).toBe(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns charHeight from device.char.height divided by DPR', () => {
|
|
||||||
const mock = createMockTerminal({
|
|
||||||
cellWidth: 8, cellHeight: 19,
|
|
||||||
deviceCharHeight: 16,
|
|
||||||
});
|
|
||||||
cleanups.push(mock.cleanup);
|
|
||||||
const dims = getCellDimensions(mock.terminal as unknown as XtermTerminal);
|
|
||||||
expect(dims).not.toBeNull();
|
|
||||||
// DPR=1, so charHeight = 16 / 1 = 16
|
|
||||||
expect(dims!.charHeight).toBe(16);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('defaults charTop to 0 when device.char not present', () => {
|
|
||||||
// Default mock has deviceCharTop=0
|
|
||||||
const mock = createMockTerminal({ cellWidth: 8, cellHeight: 19 });
|
|
||||||
cleanups.push(mock.cleanup);
|
|
||||||
const dims = getCellDimensions(mock.terminal as unknown as XtermTerminal);
|
|
||||||
expect(dims!.charTop).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('defaults charHeight to cellH when device.char.height not set', () => {
|
|
||||||
// Default mock has deviceCharHeight=cellH
|
|
||||||
const mock = createMockTerminal({ cellWidth: 8, cellHeight: 19 });
|
|
||||||
cleanups.push(mock.cleanup);
|
|
||||||
const dims = getCellDimensions(mock.terminal as unknown as XtermTerminal);
|
|
||||||
expect(dims!.charHeight).toBe(19);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('DPR simulation', () => {
|
it('returns charTop from device.char.top divided by DPR', () => {
|
||||||
const originalDPR = globalThis.devicePixelRatio;
|
const mock = createMockTerminal({
|
||||||
|
cellWidth: 8,
|
||||||
beforeEach(() => {
|
cellHeight: 19,
|
||||||
// Set DPR=2 to test division
|
deviceCharTop: 2,
|
||||||
Object.defineProperty(globalThis, 'devicePixelRatio', {
|
});
|
||||||
value: 2,
|
cleanups.push(mock.cleanup);
|
||||||
writable: true,
|
const dims = getCellDimensions(mock.terminal as unknown as XtermTerminal);
|
||||||
configurable: true,
|
expect(dims).not.toBeNull();
|
||||||
});
|
// DPR=1 in jsdom, so charTop = 2 / 1 = 2
|
||||||
});
|
expect(dims!.charTop).toBe(2);
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
Object.defineProperty(globalThis, 'devicePixelRatio', {
|
|
||||||
value: originalDPR,
|
|
||||||
writable: true,
|
|
||||||
configurable: true,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('divides device.char.top by DPR', () => {
|
|
||||||
const mock = createMockTerminal({
|
|
||||||
cellWidth: 16, cellHeight: 38,
|
|
||||||
deviceCharTop: 4,
|
|
||||||
deviceCharHeight: 32,
|
|
||||||
});
|
|
||||||
cleanups.push(mock.cleanup);
|
|
||||||
const dims = getCellDimensions(mock.terminal as unknown as XtermTerminal);
|
|
||||||
expect(dims).not.toBeNull();
|
|
||||||
// charTop = 4 / 2 = 2
|
|
||||||
expect(dims!.charTop).toBe(2);
|
|
||||||
// charHeight = 32 / 2 = 16
|
|
||||||
expect(dims!.charHeight).toBe(16);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('null cases', () => {
|
it('returns charHeight from device.char.height divided by DPR', () => {
|
||||||
it('returns null for terminal without _core', () => {
|
const mock = createMockTerminal({
|
||||||
const terminal = {
|
cellWidth: 8,
|
||||||
element: document.createElement('div'),
|
cellHeight: 19,
|
||||||
cols: 80,
|
deviceCharHeight: 16,
|
||||||
rows: 24,
|
});
|
||||||
options: {},
|
cleanups.push(mock.cleanup);
|
||||||
buffer: { active: { viewportY: 0, baseY: 0, getLine: () => undefined } },
|
const dims = getCellDimensions(mock.terminal as unknown as XtermTerminal);
|
||||||
} as unknown as XtermTerminal;
|
expect(dims).not.toBeNull();
|
||||||
const dims = getCellDimensions(terminal);
|
// DPR=1, so charHeight = 16 / 1 = 16
|
||||||
expect(dims).toBeNull();
|
expect(dims!.charHeight).toBe(16);
|
||||||
});
|
|
||||||
|
|
||||||
it('returns null for terminal with no dimensions', () => {
|
|
||||||
const terminal = {
|
|
||||||
element: document.createElement('div'),
|
|
||||||
cols: 80,
|
|
||||||
rows: 24,
|
|
||||||
options: {},
|
|
||||||
buffer: { active: { viewportY: 0, baseY: 0, getLine: () => undefined } },
|
|
||||||
_core: { _renderService: {} },
|
|
||||||
} as unknown as XtermTerminal;
|
|
||||||
const dims = getCellDimensions(terminal);
|
|
||||||
expect(dims).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('defaults charTop to 0 when device.char not present', () => {
|
||||||
|
// Default mock has deviceCharTop=0
|
||||||
|
const mock = createMockTerminal({ cellWidth: 8, cellHeight: 19 });
|
||||||
|
cleanups.push(mock.cleanup);
|
||||||
|
const dims = getCellDimensions(mock.terminal as unknown as XtermTerminal);
|
||||||
|
expect(dims!.charTop).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults charHeight to cellH when device.char.height not set', () => {
|
||||||
|
// Default mock has deviceCharHeight=cellH
|
||||||
|
const mock = createMockTerminal({ cellWidth: 8, cellHeight: 19 });
|
||||||
|
cleanups.push(mock.cleanup);
|
||||||
|
const dims = getCellDimensions(mock.terminal as unknown as XtermTerminal);
|
||||||
|
expect(dims!.charHeight).toBe(19);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('DPR simulation', () => {
|
||||||
|
const originalDPR = globalThis.devicePixelRatio;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Set DPR=2 to test division
|
||||||
|
Object.defineProperty(globalThis, 'devicePixelRatio', {
|
||||||
|
value: 2,
|
||||||
|
writable: true,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
Object.defineProperty(globalThis, 'devicePixelRatio', {
|
||||||
|
value: originalDPR,
|
||||||
|
writable: true,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('divides device.char.top by DPR', () => {
|
||||||
|
const mock = createMockTerminal({
|
||||||
|
cellWidth: 16,
|
||||||
|
cellHeight: 38,
|
||||||
|
deviceCharTop: 4,
|
||||||
|
deviceCharHeight: 32,
|
||||||
|
});
|
||||||
|
cleanups.push(mock.cleanup);
|
||||||
|
const dims = getCellDimensions(mock.terminal as unknown as XtermTerminal);
|
||||||
|
expect(dims).not.toBeNull();
|
||||||
|
// charTop = 4 / 2 = 2
|
||||||
|
expect(dims!.charTop).toBe(2);
|
||||||
|
// charHeight = 32 / 2 = 16
|
||||||
|
expect(dims!.charHeight).toBe(16);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('null cases', () => {
|
||||||
|
it('returns null for terminal without _core', () => {
|
||||||
|
const terminal = {
|
||||||
|
element: document.createElement('div'),
|
||||||
|
cols: 80,
|
||||||
|
rows: 24,
|
||||||
|
options: {},
|
||||||
|
buffer: { active: { viewportY: 0, baseY: 0, getLine: () => undefined } },
|
||||||
|
} as unknown as XtermTerminal;
|
||||||
|
const dims = getCellDimensions(terminal);
|
||||||
|
expect(dims).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for terminal with no dimensions', () => {
|
||||||
|
const terminal = {
|
||||||
|
element: document.createElement('div'),
|
||||||
|
cols: 80,
|
||||||
|
rows: 24,
|
||||||
|
options: {},
|
||||||
|
buffer: { active: { viewportY: 0, baseY: 0, getLine: () => undefined } },
|
||||||
|
_core: { _renderService: {} },
|
||||||
|
} as unknown as XtermTerminal;
|
||||||
|
const dims = getCellDimensions(terminal);
|
||||||
|
expect(dims).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,188 @@
|
|||||||
|
/**
|
||||||
|
* @vitest-environment jsdom
|
||||||
|
*
|
||||||
|
* Layer 2 (the load-bearing suite): the REAL algorithm against the REAL xterm
|
||||||
|
* parser, fed by fixtures recorded from real codex 0.147 through the
|
||||||
|
* production pipeline (tmux + the codex full strip). See
|
||||||
|
* scripts/dev/record-codex-frames.mjs in the consuming repo.
|
||||||
|
*
|
||||||
|
* Every replay ends with the convergence invariant: predictions never outlive
|
||||||
|
* their run (outstanding 0, span container empty).
|
||||||
|
*/
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { PredictiveEchoAddon } from '../src/predictive-echo-addon.js';
|
||||||
|
import {
|
||||||
|
CELL_H,
|
||||||
|
CELL_W,
|
||||||
|
classifyPredictInput,
|
||||||
|
codexComposerGate,
|
||||||
|
createReplayTerminal,
|
||||||
|
loadFixture,
|
||||||
|
type ReplayTerminal,
|
||||||
|
} from './replay-helpers.js';
|
||||||
|
|
||||||
|
async function flushMicrotasks() {
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
function sleep(ms: number) {
|
||||||
|
return new Promise((r) => setTimeout(r, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
interface KeyEvent {
|
||||||
|
key: string;
|
||||||
|
kind: ReturnType<typeof classifyPredictInput>;
|
||||||
|
painted: boolean;
|
||||||
|
spansAfter: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertSpansInGrid(rt: ReplayTerminal) {
|
||||||
|
for (const s of rt.spans()) {
|
||||||
|
const left = parseFloat(s.style.left);
|
||||||
|
const width = parseFloat(s.style.width);
|
||||||
|
const top = parseFloat(s.style.top);
|
||||||
|
expect(left + width).toBeLessThanOrEqual(rt.hybrid.cols * CELL_W);
|
||||||
|
expect(top).toBeLessThanOrEqual((rt.hybrid.rows - 1) * CELL_H);
|
||||||
|
expect(left).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(top).toBeGreaterThanOrEqual(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function replay(name: string) {
|
||||||
|
const { meta, lines } = loadFixture(name);
|
||||||
|
const rt = createReplayTerminal(meta.cols, meta.rows);
|
||||||
|
const addon = new PredictiveEchoAddon({ predictWhen: codexComposerGate });
|
||||||
|
addon.activate(rt.hybrid);
|
||||||
|
|
||||||
|
const events: KeyEvent[] = [];
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.keyAt) {
|
||||||
|
const kind = classifyPredictInput(line.data);
|
||||||
|
let painted = false;
|
||||||
|
if (kind === 'char') painted = addon.predictChar(line.data);
|
||||||
|
else if (kind === 'backspace') addon.predictBackspace();
|
||||||
|
else addon.clearPredictions(); // 'clear' AND 'text', like the terminal-ui hook
|
||||||
|
// Span/record parity and grid bounds hold at every step
|
||||||
|
expect(rt.spanCount()).toBe(addon.state.outstanding);
|
||||||
|
assertSpansInGrid(rt);
|
||||||
|
events.push({ key: line.data, kind, painted, spansAfter: rt.spanCount() });
|
||||||
|
} else {
|
||||||
|
await rt.write(line.data);
|
||||||
|
await flushMicrotasks();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { rt, addon, events, meta };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convergence invariant: after the last chunk + reconcile (+ TTL if needed),
|
||||||
|
* nothing outlives the run. */
|
||||||
|
async function converge(rt: ReplayTerminal, addon: PredictiveEchoAddon) {
|
||||||
|
addon.reconcile();
|
||||||
|
if (addon.state.outstanding > 0) {
|
||||||
|
await sleep(1100); // ttlMs default
|
||||||
|
addon.reconcile();
|
||||||
|
}
|
||||||
|
expect(addon.state.outstanding).toBe(0);
|
||||||
|
expect(rt.spanCount()).toBe(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('codex replay', () => {
|
||||||
|
it('type-hello: all 5 predictions confirm, zero drops, composer converges', async () => {
|
||||||
|
const { rt, addon, events } = await replay('type-hello');
|
||||||
|
const chars = events.filter((e) => e.kind === 'char');
|
||||||
|
expect(chars).toHaveLength(5);
|
||||||
|
expect(chars.every((e) => e.painted)).toBe(true);
|
||||||
|
await converge(rt, addon);
|
||||||
|
expect(addon.state.confirmedTotal).toBe(5);
|
||||||
|
expect(addon.state.droppedTotal).toBe(0);
|
||||||
|
expect(rt.cursorRowText()).toBe('› hello');
|
||||||
|
addon.dispose();
|
||||||
|
rt.cleanup();
|
||||||
|
}, 15000);
|
||||||
|
|
||||||
|
it('slash-picker: "/" and filter chars confirm; no ghosts while picker rows redraw', async () => {
|
||||||
|
const { rt, addon, events } = await replay('slash-picker');
|
||||||
|
const chars = events.filter((e) => e.kind === 'char');
|
||||||
|
expect(chars.map((e) => e.key)).toEqual(['/', 'm', 'o']);
|
||||||
|
expect(chars.every((e) => e.painted)).toBe(true);
|
||||||
|
await converge(rt, addon);
|
||||||
|
expect(addon.state.confirmedTotal).toBe(3);
|
||||||
|
expect(addon.state.droppedTotal).toBe(0);
|
||||||
|
addon.dispose();
|
||||||
|
rt.cleanup();
|
||||||
|
}, 15000);
|
||||||
|
|
||||||
|
it('wrap: predictions stay inside the grid, continuation rows fall back to real echo, buffer converges', async () => {
|
||||||
|
const { rt, addon, events } = await replay('wrap');
|
||||||
|
// The gate goes false once the cursor is on a wrapped continuation row
|
||||||
|
// (2-space indent, no "› "): a tail of keystrokes must be suppressed.
|
||||||
|
const chars = events.filter((e) => e.kind === 'char');
|
||||||
|
expect(chars.some((e) => !e.painted)).toBe(true);
|
||||||
|
expect(chars.some((e) => e.painted)).toBe(true);
|
||||||
|
await converge(rt, addon);
|
||||||
|
// The composer content is exactly what was typed (word-wrapped)
|
||||||
|
const b = rt.term.buffer.active;
|
||||||
|
const cursorRow = b.cursorY;
|
||||||
|
expect(rt.rowText(cursorRow).trim()).toBe('this line twice over');
|
||||||
|
expect(rt.rowText(cursorRow - 1)).toMatch(/^› the quick brown fox/);
|
||||||
|
addon.dispose();
|
||||||
|
rt.cleanup();
|
||||||
|
}, 15000);
|
||||||
|
|
||||||
|
it('streaming-burst: typed predictions confirm; the re-rendered composer keeps its signature', async () => {
|
||||||
|
const { rt, addon, events } = await replay('streaming-burst');
|
||||||
|
const chars = events.filter((e) => e.kind === 'char');
|
||||||
|
expect(chars).toHaveLength(5); // "hello" (the \r is kind 'clear')
|
||||||
|
await converge(rt, addon);
|
||||||
|
expect(addon.state.confirmedTotal).toBe(5);
|
||||||
|
expect(addon.state.droppedTotal).toBe(0);
|
||||||
|
// After the 401 burst codex re-renders a fresh composer at the cursor
|
||||||
|
expect(rt.cursorRowText()).toMatch(/^› /);
|
||||||
|
addon.dispose();
|
||||||
|
rt.cleanup();
|
||||||
|
}, 15000);
|
||||||
|
|
||||||
|
it('streaming-real: mid-stream typing survives real baseY growth (recorded with real auth)', async () => {
|
||||||
|
// The one shape the fake-key lab cannot produce: a genuine model reply
|
||||||
|
// streaming above the pinned composer pushes lines into history, so
|
||||||
|
// baseY GROWS while predictions are outstanding: the no-drop-on-baseY
|
||||||
|
// rule against reality instead of a synthetic scroll.
|
||||||
|
const { rt, addon, events } = await replay('streaming-real');
|
||||||
|
expect(rt.term.buffer.active.baseY).toBeGreaterThan(0); // history really grew
|
||||||
|
const midStream = events.filter((e) => e.kind === 'char' && ['a', 'b', 'c'].includes(e.key));
|
||||||
|
expect(midStream.length).toBe(3);
|
||||||
|
expect(midStream.some((e) => e.painted)).toBe(true); // predictions ran mid-stream
|
||||||
|
await converge(rt, addon);
|
||||||
|
expect(rt.cursorRowText()).toBe('› abc'); // the mid-stream chars landed intact
|
||||||
|
addon.dispose();
|
||||||
|
rt.cleanup();
|
||||||
|
}, 15000);
|
||||||
|
|
||||||
|
it('paste-bracketed: typed chars confirm, the paste clears predictions, content intact', async () => {
|
||||||
|
const { rt, addon, events } = await replay('paste-bracketed');
|
||||||
|
const paste = events.find((e) => e.key.startsWith('\x1b[200~'))!;
|
||||||
|
expect(paste.kind).toBe('clear');
|
||||||
|
expect(paste.spansAfter).toBe(0);
|
||||||
|
await converge(rt, addon);
|
||||||
|
expect(addon.state.confirmedTotal).toBe(2); // 'a', 'b'
|
||||||
|
expect(rt.cursorRowText()).toContain('abXYZpasted');
|
||||||
|
addon.dispose();
|
||||||
|
rt.cleanup();
|
||||||
|
}, 15000);
|
||||||
|
|
||||||
|
it('trust-modal: the predictWhen gate paints ZERO spans on the modal (ghost eliminator)', async () => {
|
||||||
|
const { rt, addon, events } = await replay('trust-modal');
|
||||||
|
const x = events.find((e) => e.key === 'x')!;
|
||||||
|
expect(x.painted).toBe(false);
|
||||||
|
expect(x.spansAfter).toBe(0);
|
||||||
|
expect(events.every((e) => e.spansAfter === 0)).toBe(true);
|
||||||
|
await converge(rt, addon);
|
||||||
|
expect(addon.state.confirmedTotal).toBe(0);
|
||||||
|
expect(addon.state.droppedTotal).toBe(0);
|
||||||
|
// The transition landed on the real composer afterwards
|
||||||
|
expect(rt.cursorRowText()).toMatch(/^› /);
|
||||||
|
addon.dispose();
|
||||||
|
rt.cleanup();
|
||||||
|
}, 15000);
|
||||||
|
});
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
{"scenario":"paste-bracketed","cols":100,"rows":30,"codexVersion":"codex-cli 0.147.0","recordedAt":"2026-08-09T01:51:11.762Z"}
|
||||||
|
{"delayMs":0,"data":"\u001b[22;0;0t\u001b[?1h\u001b=\u001b[H\u001b[2J\u001b[?12l\u001b[?25h\u001b[?2004h\u001b(B\u001b[m\u001b[?12l\u001b[?25h\u001b[1;1H\u001b[1;30r\u001b[c\u001b[>c\u001b[>q\u001b]10;?\u001b\\\u001b]11;?\u001b\\\u001b[1;1H\u001b[?25l\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\u001b[?12l\u001b[?25h\u001b[H"}
|
||||||
|
{"delayMs":0,"data":"\u001b(B\u001b[m\u001b[?12l\u001b[?25h\u001b[1;1H\u001b[1;30r\u001b[1;1H"}
|
||||||
|
{"delayMs":0,"data":"\u001b[?25l\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\u001b[?12l\u001b[?25h\u001b[H"}
|
||||||
|
{"delayMs":45,"data":"\u001b[32m\u001b[1markon@tnode\u001b(B\u001b[m:\u001b[34m\u001b[1m~/default/claudeman-predictive/tmp/codexrec-work-bWhHjh\u001b(B\u001b[m$ "}
|
||||||
|
{"delayMs":638,"data":"exec codex\r\n"}
|
||||||
|
{"delayMs":420,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"}
|
||||||
|
{"delayMs":182,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"}
|
||||||
|
{"delayMs":5,"data":"\r\n\u001b[J\u001b[A\u001b[K"}
|
||||||
|
{"delayMs":0,"data":"\u001b[2;30r\u001b[2;1H\u001bM\u001bM\u001bM\u001b[33m⚠ Codex could not find bubblewrap on PATH. Install bubblewrap with your OS package manager. See the\r\n\u001b[39m \u001b[33msandbox prerequisites: https://developers.openai.com/codex/concepts/sandboxing#prerequisites.\u001b[1;30r\u001b[4;1H\u001b(B\u001b[m"}
|
||||||
|
{"delayMs":1,"data":" \u001b[33mCodex will use the bundled bubblewrap in the meantime.\u001b[6;1H\u001b[39m\u001b[2m╭─────────────────────────────────────────────────╮\r\n│ >_ \u001b(B\u001b[m\u001b[1mOpenAI Codex\u001b(B\u001b[m\u001b[2m (v0.147.0) │\r\n│ │\r\n│ model: \u001b[3mloading\u001b(B\u001b[m\u001b[2m \u001b(B\u001b[m\u001b[36m/model\u001b[39m\u001b[2m to change │\r\n│ directory: \u001b(B\u001b[m~/default/…/tmp/codexrec-work-bWhHjh\u001b[2m │\r\n╰─────────────────────────────────────────────────╯\u001b[14;1H\u001b(B\u001b[m\u001b[1m›\u001b[C\u001b(B\u001b[m\u001b[2mSummarize rec\u001b(B\u001b[m\u001b[2ment commits\u001b[16;3H\u001b(B\u001b[m\u001b[38;5;223mgpt-5.6-sol default\u001b[39m\u001b[2m · \u001b(B\u001b[m\u001b[38;5;151m~/default/claudeman-predictive/tmp/codexrec-work-bWhHjh\u001b[14;3H\u001b(B\u001b[m"}
|
||||||
|
{"delayMs":7,"data":"\u001b[6;52H\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\u001b[14;27H\u001b[K\u001b[16;80H\u001b[K\u001b[14;3H"}
|
||||||
|
{"delayMs":21,"data":"\u001b[6;52H\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\u001b[14;27H\u001b[K\u001b[16;80H\u001b[K\u001b[14;3H"}
|
||||||
|
{"delayMs":8,"data":"\u001b[6;52H\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\u001b[14;27H\u001b[K\u001b[16;80H\u001b[K\u001b[14;3H"}
|
||||||
|
{"delayMs":159,"data":"\u001b[6;1H\u001b[J\u001b[A\u001b[K"}
|
||||||
|
{"delayMs":0,"data":"\u001b[2B\u001b[1m›\u001b[C\u001b(B\u001b[m\u001b[2mSummarize recent commits\u001b[9;3H\u001b(B\u001b[m\u001b[38;5;223mgpt-5.6-sol default\u001b[39m\u001b[2m · \u001b(B\u001b[m\u001b[38;5;151m~/default/claudeman-predictive/tmp/codexrec-work-bWhHjh\u001b[7;3H\u001b(B\u001b[m"}
|
||||||
|
{"delayMs":21,"data":"\u001b[5;30r\u001b[5;1H\u001bM\u001bM\u001bM\u001bM\u001bM\u001bM\u001bM\u001bM\u001bM\u001bM\u001bM\r\n\u001b[2m╭─────────────────────────────────────────────────╮\u001b[1;30r\u001b[7;1H\u001b(B\u001b[m\u001b[2m│ >_ \u001b(B\u001b[m\u001b[1mOpenAI Codex\u001b(B\u001b[m\u001b[2m (v0.147.0) │\r\n│ │\r\n│ model: \u001b(B\u001b[mgpt-5.6-sol\u001b[2m \u001b(B\u001b[m\u001b[36m/model\u001b[39m\u001b[2m to change │\r\n│ directory: \u001b(B\u001b[m~/default/…/tmp/codexrec-work-bWhHjh\u001b[2m │\r\n╰─────────────────────────────────────────────────╯\u001b[13;1H\u001b(B\u001b[m \u001b[1mTip:\u001b(B\u001b[m Our most capable model yet. GPT-5.6 Sol can tackle complex code changes, dig into research,\r\n produce polished documents, and take on your most ambitious work. Sol is highly capable at lower\r\n"}
|
||||||
|
{"delayMs":0,"data":" reasoning efforts—try starting lower, then turn it up for harder jobs.\u001b[18;27H\u001b[K\u001b[20;80H\u001b[K\u001b[18;3H"}
|
||||||
|
{"delayMs":0,"data":"\u001b[24C\u001b[K\u001b[20;80H\u001b[K\u001b[18;3H"}
|
||||||
|
{"delayMs":0,"data":"\u001b[24C\u001b[K\u001b[20;80H\u001b[K\u001b[18;3H"}
|
||||||
|
{"delayMs":3487,"data":"\u001b[?7727h\u001b(B\u001b[m\u001b[?12l\u001b[?25h\u001b[1;1H\u001b[1;30r\u001b[18;3H"}
|
||||||
|
{"delayMs":0,"data":"\u001b[?25l\u001b[32m\u001b[1m\u001b[Harkon@tnode\u001b(B\u001b[m:\u001b[34m\u001b[1m~/default/claudeman-predictive/tmp/codexrec-work-bWhHjh\u001b(B\u001b[m$ exec codex\u001b[K\u001b[33m\r\n⚠ Codex could not find bubblewrap on PATH. Install bubblewrap with your OS package manager. See the\u001b[39m\u001b[K\r\n \u001b[33msandbox prerequisites: https://developers.openai.com/codex/concepts/sandboxing#prerequisites.\u001b[39m\u001b[K\r\n \u001b[33mCodex will use the bundled bubblewrap in the meantime.\u001b[39m\u001b[K\r\n\u001b[K\u001b[2m\r\n╭─────────────────────────────────────────────────╮\u001b(B\u001b[m\u001b[K\u001b[2m\r\n│ >_ \u001b(B\u001b[m\u001b[1mOpenAI Codex\u001b(B\u001b[m\u001b[2m (v0.147.0) │\u001b(B\u001b[m\u001b[K\u001b[2m\r\n│ │\u001b(B\u001b[m\u001b[K\u001b[2m\r\n│ model: \u001b(B\u001b[mgpt-5.6-sol\u001b[2m \u001b(B\u001b[m\u001b[36m/model\u001b[39m\u001b[2m to change │\u001b(B\u001b[m\u001b[K\u001b[2m\r\n│ directory: \u001b(B\u001b[m~/default/…/tmp/codexrec-work-bWhHjh\u001b[2m │\u001b(B\u001b[m\u001b[K\u001b[2m\r\n╰─────────────────────────────────────────────────╯\u001b(B\u001b[m\u001b[K\r\n\u001b[K\r\n \u001b[1mTip:\u001b(B\u001b[m Our most capable model yet. GPT-5.6 Sol can tackle complex code changes, dig into research,\u001b[K\r\n produce polished documents, and take on your most ambitious work. Sol is highly capable at lower\u001b[K\r\n reasoning efforts—try starting lower, then turn it up for harder jobs.\u001b[K\r\n\u001b[K\r\n\u001b[K\u001b[1m\r\n›\u001b(B\u001b[m\u001b[1X\u001b[2m\u001b[CSummarize recent commits\u001b(B\u001b[m\u001b[K\r\n\u001b[K\u001b[20;2H\u001b[1K\u001b[38;5;223m\u001b[Cgpt-5.6-sol default\u001b[39m\u001b[2m · \u001b(B\u001b[m\u001b[38;5;151m~/default/claudeman-predictive/tmp/codexrec-work-bWhHjh\u001b[39m\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\u001b[?12l\u001b[?25h\u001b[18;3H"}
|
||||||
|
{"keyAt":true,"data":"a"}
|
||||||
|
{"delayMs":207,"data":"a\u001b[K\u001b[20;80H\u001b[K\u001b[18;4H"}
|
||||||
|
{"keyAt":true,"data":"b"}
|
||||||
|
{"delayMs":91,"data":"b\u001b[K\u001b[20;80H\u001b[K\u001b[18;5H"}
|
||||||
|
{"keyAt":true,"data":"\u001b[200~XYZpasted\u001b[201~"}
|
||||||
|
{"delayMs":383,"data":"XYZpasted\u001b[K\u001b[20;80H\u001b[K\u001b[18;14H"}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
{"scenario":"slash-picker","cols":100,"rows":30,"codexVersion":"codex-cli 0.147.0","recordedAt":"2026-08-09T01:50:42.069Z"}
|
||||||
|
{"delayMs":0,"data":"\u001b[22;0;0t\u001b[?1h\u001b=\u001b[H\u001b[2J\u001b[?12l\u001b[?25h\u001b[?2004h\u001b(B\u001b[m\u001b[?12l\u001b[?25h\u001b[1;1H\u001b[1;30r\u001b[c\u001b[>c\u001b[>q\u001b]10;?\u001b\\\u001b]11;?\u001b\\\u001b[1;1H"}
|
||||||
|
{"delayMs":0,"data":"\u001b[?25l\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\u001b[?12l\u001b[?25h\u001b[H"}
|
||||||
|
{"delayMs":0,"data":"\u001b(B\u001b[m\u001b[?12l\u001b[?25h\u001b[1;1H\u001b[1;30r\u001b[1;1H"}
|
||||||
|
{"delayMs":0,"data":"\u001b[?25l\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\u001b[?12l\u001b[?25h\u001b[H"}
|
||||||
|
{"delayMs":37,"data":"\u001b[32m\u001b[1markon@tnode\u001b(B\u001b[m:\u001b[34m\u001b[1m~/default/claudeman-predictive/tmp/codexrec-work-bw9Uto\u001b(B\u001b[m$ "}
|
||||||
|
{"delayMs":647,"data":"exec codex\r\n"}
|
||||||
|
{"delayMs":437,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"}
|
||||||
|
{"delayMs":183,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"}
|
||||||
|
{"delayMs":8,"data":"\r\n\u001b[J\u001b[A\u001b[K\u001b[2;30r\u001b[2;1H\u001bM\u001bM\u001bM\u001b[33m⚠ Codex could not find bubblewrap on PATH. Install bubblewrap with your OS package manager. See the\r\n\u001b[39m \u001b[33msandbox prerequisites: https://developers.openai.com/codex/concepts/sandboxing#prerequisites.\r\n\u001b[39m \u001b[33mCodex will use the bundled bubblewrap in the meantime.\u001b[6;1H\u001b[39m\u001b[2m╭─────────────────────────────────────────────────╮\r\n│ >_ \u001b(B\u001b[m\u001b[1mOpenAI Codex\u001b(B\u001b[m\u001b[2m (v0.147.0) │\r\n│ │\r\n│ model: \u001b[3mloading\u001b(B\u001b[m\u001b[2m \u001b(B\u001b[m\u001b[36m/model\u001b[39m\u001b[2m to change │\r\n│ directory: \u001b(B\u001b[m~/default/…/tmp/codexrec-work-bw9Uto\u001b[2m │\r\n╰─────────────────────────────────────────────────╯\u001b[14;1H\u001b(B\u001b[m\u001b[1m›\u001b[C\u001b(B\u001b[m\u001b[2mUse /skills to list available skills\u001b[16;3H\u001b(B\u001b[m\u001b[38;5;223mgpt-5.6-sol default\u001b[39m\u001b[2m · \u001b(B\u001b[m\u001b[38;5;151m~/default/claudeman-predictive/tmp/codexrec-work-bw9Uto\u001b[1;30r\u001b[14;3H\u001b(B\u001b[m"}
|
||||||
|
{"delayMs":9,"data":"\u001b[6;52H\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\u001b[14;39H\u001b[K\u001b[16;80H\u001b[K\u001b[14;3H"}
|
||||||
|
{"delayMs":12,"data":"\u001b[6;52H\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\u001b[14;39H\u001b[K\u001b[16;80H\u001b[K\u001b[14;3H"}
|
||||||
|
{"delayMs":10,"data":"\u001b[6;52H\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\u001b[14;39H\u001b[K\u001b[16;80H\u001b[K\u001b[14;3H"}
|
||||||
|
{"delayMs":157,"data":"\u001b[6;1H\u001b[J\u001b[A\u001b[K"}
|
||||||
|
{"delayMs":0,"data":"\u001b[2B\u001b[1m›\u001b[C\u001b(B\u001b[m\u001b[2mUse /skills to list available skills\u001b[9;3H\u001b(B\u001b[m\u001b[38;5;223mgpt-5.6-sol default\u001b[39m\u001b[2m · \u001b(B\u001b[m\u001b[38;5;151m~/default/claudeman-predictive/tmp/codexrec-work-bw9Uto\u001b[7;3H\u001b(B\u001b[m"}
|
||||||
|
{"delayMs":20,"data":"\u001b[5;30r\u001b[5;1H\u001bM\u001bM\u001bM\u001bM\u001bM\u001bM\u001bM\u001bM\u001bM\u001bM\u001bM\r\n\u001b[2m╭─────────────────────────────────────────────────╮\u001b[1;30r\u001b[7;1H\u001b(B\u001b[m"}
|
||||||
|
{"delayMs":0,"data":"\u001b[2m│ >_ \u001b(B\u001b[m\u001b[1mOpenAI Codex\u001b(B\u001b[m\u001b[2m (v0.147.0) │\r\n│ │\r\n│ model: \u001b(B\u001b[mgpt-5.6-sol\u001b[2m \u001b(B\u001b[m\u001b[36m/model\u001b[39m\u001b[2m to change │\r\n│ directory: \u001b(B\u001b[m~/default/…/tmp/codexrec-work-bw9Uto\u001b[2m │\r\n╰─────────────────────────────────────────────────╯\u001b[13;1H\u001b(B\u001b[m \u001b[1mTip:\u001b(B\u001b[m Our most capable model yet. GPT-5.6 Sol can tackle complex code changes, dig into research,\r\n produce polished documents, and take on your most ambitious work. Sol is highly capable at lower\r\n"}
|
||||||
|
{"delayMs":0,"data":" reasoning efforts—try starting lower, then turn it up for harder jobs.\u001b[18;39H\u001b[K\u001b[20;80H\u001b[K\u001b[18;3H"}
|
||||||
|
{"delayMs":0,"data":"\u001b[36C\u001b[K\u001b[20;80H\u001b[K\u001b[18;3H"}
|
||||||
|
{"delayMs":0,"data":"\u001b[36C\u001b[K\u001b[20;80H\u001b[K\u001b[18;3H"}
|
||||||
|
{"delayMs":3476,"data":"\u001b[?7727h\u001b(B\u001b[m\u001b[?12l\u001b[?25h\u001b[1;1H\u001b[1;30r\u001b[18;3H"}
|
||||||
|
{"delayMs":0,"data":"\u001b[?25l\u001b[32m\u001b[1m\u001b[Harkon@tnode\u001b(B\u001b[m:\u001b[34m\u001b[1m~/default/claudeman-predictive/tmp/codexrec-work-bw9Uto\u001b(B\u001b[m$ exec codex\u001b[K\u001b[33m\r\n⚠ Codex could not find bubblewrap on PATH. Install bubblewrap with your OS package manager. See the\u001b[39m\u001b[K\r\n \u001b[33msandbox prerequisites: https://developers.openai.com/codex/concepts/sandboxing#prerequisites.\u001b[39m\u001b[K\r\n \u001b[33mCodex will use the bundled bubblewrap in the meantime.\u001b[39m\u001b[K\r\n\u001b[K\u001b[2m\r\n╭─────────────────────────────────────────────────╮\u001b(B\u001b[m\u001b[K\u001b[2m\r\n│ >_ \u001b(B\u001b[m\u001b[1mOpenAI Codex\u001b(B\u001b[m\u001b[2m (v0.147.0) │\u001b(B\u001b[m\u001b[K\u001b[2m\r\n│ │\u001b(B\u001b[m\u001b[K\u001b[2m\r\n│ model: \u001b(B\u001b[mgpt-5.6-sol\u001b[2m \u001b(B\u001b[m\u001b[36m/model\u001b[39m\u001b[2m to change │\u001b(B\u001b[m\u001b[K\u001b[2m\r\n│ directory: \u001b(B\u001b[m~/default/…/tmp/codexrec-work-bw9Uto\u001b[2m │\u001b(B\u001b[m\u001b[K\u001b[2m\r\n╰─────────────────────────────────────────────────╯\u001b(B\u001b[m\u001b[K\r\n\u001b[K\r\n \u001b[1mTip:\u001b(B\u001b[m Our most capable model yet. GPT-5.6 Sol can tackle complex code changes, dig into research,\u001b[K\r\n produce polished documents, and take on your most ambitious work. Sol is highly capable at lower\u001b[K\r\n reasoning efforts—try starting lower, then turn it up for harder jobs.\u001b[K\r\n\u001b[K\r\n\u001b[K\u001b[1m\r\n›\u001b(B\u001b[m\u001b[1X\u001b[2m\u001b[CUse /skills to list available skills\u001b(B\u001b[m\u001b[K\r\n\u001b[K\u001b[20;2H\u001b[1K\u001b[38;5;223m\u001b[Cgpt-5.6-sol default\u001b[39m\u001b[2m · \u001b(B\u001b[m\u001b[38;5;151m~/default/claudeman-predictive/tmp/codexrec-work-bw9Uto\u001b[39m\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\u001b[?12l\u001b[?25h\u001b[18;3H"}
|
||||||
|
{"keyAt":true,"data":"/"}
|
||||||
|
{"delayMs":207,"data":"\u001b[17;1H\u001b[J\u001b[A\u001b[K"}
|
||||||
|
{"delayMs":1,"data":"\u001b[2B\u001b[1m›\u001b[C\u001b(B\u001b[m/\u001b[20;3H\u001b[36m\u001b[1m/model choose what model and reasoning effort to use\u001b[21;3H\u001b(B\u001b[m/fast\u001b[10C\u001b[2m1.5x speed, increased usage\u001b[22;3H\u001b(B\u001b[m/ide\u001b[11C\u001b[2minclude current selection, open files, and other context from your IDE\u001b[23;3H\u001b(B\u001b[m/permissions\u001b[3C\u001b[2mchoose what Codex is allowed to do\u001b[24;3H\u001b(B\u001b[m/keymap\u001b[8C\u001b[2mremap TUI shortcuts\u001b[25;3H\u001b(B\u001b[m/vim\u001b[11C\u001b[2mtoggle Vim mode for the composer\u001b[26;3H\u001b(B\u001b[m/experimental\u001b[2C\u001b[2mtoggle experimental features\u001b[27;3H\u001b(B\u001b[m/approve\u001b[7C\u001b[2mapprove one retry of a recent auto-review denial\u001b[18;4H\u001b(B\u001b[m"}
|
||||||
|
{"keyAt":true,"data":"m"}
|
||||||
|
{"delayMs":398,"data":"\u001b[17;1H\u001b[J\u001b[A\u001b[K"}
|
||||||
|
{"delayMs":1,"data":"\u001b[2B\u001b[1m›\u001b[C\u001b(B\u001b[m/m\u001b[20;3H\u001b[36m\u001b[1m/model choose what model and reasoning effort to use\u001b[21;3H\u001b(B\u001b[m/\u001b[1mm\u001b(B\u001b[memories\u001b[2C\u001b[2mconfigure memory use and generation\u001b[22;3H\u001b(B\u001b[m/\u001b[1mm\u001b(B\u001b[mention\u001b[3C\u001b[2mmention a file\u001b[23;3H\u001b(B\u001b[m/\u001b[1mm\u001b(B\u001b[mcp\u001b[7C\u001b[2mlist configured MCP tools; use /mcp verbose for details\u001b[18;5H\u001b(B\u001b[m"}
|
||||||
|
{"keyAt":true,"data":"o"}
|
||||||
|
{"delayMs":148,"data":"\u001b[17;1H\u001b[J\u001b[A\u001b[K"}
|
||||||
|
{"delayMs":0,"data":"\u001b[2B\u001b[1m›\u001b[C\u001b(B\u001b[m/mo\u001b[20;3H\u001b[36m\u001b[1m/model choose what model and reasoning effort to use\u001b[18;6H\u001b(B\u001b[m"}
|
||||||
|
{"keyAt":true,"data":"\u001b"}
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
{"scenario":"streaming-burst","cols":100,"rows":30,"codexVersion":"codex-cli 0.147.0","recordedAt":"2026-08-09T01:51:03.828Z"}
|
||||||
|
{"delayMs":0,"data":"\u001b[22;0;0t\u001b[?1h\u001b=\u001b[H\u001b[2J\u001b[?12l\u001b[?25h\u001b[?2004h\u001b(B\u001b[m\u001b[?12l\u001b[?25h\u001b[1;1H\u001b[1;30r\u001b[c\u001b[>c\u001b[>q\u001b]10;?\u001b\\\u001b]11;?\u001b\\\u001b[1;1H"}
|
||||||
|
{"delayMs":0,"data":"\u001b[?25l\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\u001b[?12l\u001b[?25h\u001b[H"}
|
||||||
|
{"delayMs":0,"data":"\u001b(B\u001b[m\u001b[?12l\u001b[?25h\u001b[1;1H\u001b[1;30r\u001b[1;1H"}
|
||||||
|
{"delayMs":0,"data":"\u001b[?25l\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\u001b[?12l\u001b[?25h\u001b[H"}
|
||||||
|
{"delayMs":39,"data":"\u001b[32m\u001b[1markon@tnode\u001b(B\u001b[m:\u001b[34m\u001b[1m~/default/claudeman-predictive/tmp/codexrec-work-ruT16A\u001b(B\u001b[m$ "}
|
||||||
|
{"delayMs":635,"data":"exec codex\r\n"}
|
||||||
|
{"delayMs":439,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"}
|
||||||
|
{"delayMs":184,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"}
|
||||||
|
{"delayMs":9,"data":"\r\n\u001b[J\u001b[A\u001b[K\u001b[2;30r\u001b[2;1H\u001bM\u001bM\u001bM\u001b[1;30r\u001b[2;1H\u001b[33m⚠ Codex could not find bubblewrap on PATH. Install bubblewrap with your OS package manager. See the\r\n\u001b[39m \u001b[33msandbox prerequisites: https://developers.openai.com/codex/concepts/sandboxing#prerequisites.\r\n\u001b(B\u001b[m \u001b[33mCodex will use the bundled bubblewrap in the meantime.\u001b[6;1H\u001b[39m\u001b[2m╭─────────────────────────────────────────────────╮\r\n│ >_ \u001b(B\u001b[m\u001b[1mOpenAI Codex\u001b(B\u001b[m\u001b[2m (v0.147.0) │\r\n│ │\r\n│ model: \u001b[3mloading\u001b(B\u001b[m\u001b[2m \u001b(B\u001b[m\u001b[36m/model\u001b[39m\u001b[2m to change │\r\n│ directory: \u001b(B\u001b[m~/default/…/tmp/codexrec-work-ruT16A\u001b[2m │\r\n╰─────────────────────────────────────────────────╯\u001b[14;1H\u001b(B\u001b[m\u001b[1m›\u001b[C\u001b(B\u001b[m\u001b[2mUse /skills to list available skills\u001b[16;3H\u001b(B\u001b[m\u001b[38;5;223mgpt-5.6-sol default\u001b[39m\u001b[2m · \u001b(B\u001b[m\u001b[38;5;151m~/default/claudeman-predictive/tmp/codexrec-work-ruT16A\u001b[14;3H\u001b(B\u001b[m"}
|
||||||
|
{"delayMs":8,"data":"\u001b[6;52H\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\u001b[14;39H\u001b[K\u001b[16;80H\u001b[K\u001b[14;3H"}
|
||||||
|
{"delayMs":8,"data":"\u001b[6;52H\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\u001b[14;39H\u001b[K\u001b[16;80H\u001b[K\u001b[14;3H"}
|
||||||
|
{"delayMs":8,"data":"\u001b[6;52H\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\u001b[14;39H\u001b[K\u001b[16;80H\u001b[K\u001b[14;3H"}
|
||||||
|
{"delayMs":158,"data":"\u001b[6;1H\u001b[J\u001b[A\u001b[K"}
|
||||||
|
{"delayMs":0,"data":"\u001b[2B\u001b[1m›\u001b[C\u001b(B\u001b[m\u001b[2mUse /skills to list available skills\u001b[9;3H\u001b(B\u001b[m\u001b[38;5;223mgpt-5.6-sol default\u001b[39m\u001b[2m · \u001b(B\u001b[m\u001b[38;5;151m~/default/claudeman-predictive/tmp/codexrec-work-ruT16A\u001b[7;3H\u001b(B\u001b[m"}
|
||||||
|
{"delayMs":21,"data":"\u001b[5;30r\u001b[5;1H\u001bM\u001bM\u001bM\u001bM\u001bM\u001bM\u001bM\u001bM\u001bM\u001bM\u001bM\r\n\u001b[2m╭─────────────────────────────────────────────────╮\u001b[1;30r\u001b[7;1H\u001b(B\u001b[m\u001b[2m│ >_ \u001b(B\u001b[m\u001b[1mOpenAI Codex\u001b(B\u001b[m\u001b[2m (v0.147.0) │\r\n│ │\r\n│ model: \u001b(B\u001b[mgpt-5.6-sol\u001b[2m \u001b(B\u001b[m\u001b[36m/model\u001b[39m\u001b[2m to change │\r\n│ directory: \u001b(B\u001b[m~/default/…/tmp/codexrec-work-ruT16A\u001b[2m │\r\n╰─────────────────────────────────────────────────╯\u001b[13;1H\u001b(B\u001b[m \u001b[1mTip:\u001b(B\u001b[m Our most capable model yet. GPT-5.6 Sol can tackle complex code changes, dig into research,\r\n produce polished documents, and take on your most ambitious work. Sol is highly capable at lower\r\n"}
|
||||||
|
{"delayMs":0,"data":" reasoning efforts—try starting lower, then turn it up for harder jobs.\u001b[18;39H\u001b[K\u001b[20;80H\u001b[K\u001b[18;3H"}
|
||||||
|
{"delayMs":0,"data":"\u001b[36C\u001b[K\u001b[20;80H\u001b[K\u001b[18;3H"}
|
||||||
|
{"delayMs":3486,"data":"\u001b[?7727h\u001b(B\u001b[m\u001b[?12l\u001b[?25h\u001b[1;1H\u001b[1;30r\u001b[18;3H"}
|
||||||
|
{"delayMs":0,"data":"\u001b[?25l\u001b[32m\u001b[1m\u001b[Harkon@tnode\u001b(B\u001b[m:\u001b[34m\u001b[1m~/default/claudeman-predictive/tmp/codexrec-work-ruT16A\u001b(B\u001b[m$ exec codex\u001b[K\u001b[33m\r\n⚠ Codex could not find bubblewrap on PATH. Install bubblewrap with your OS package manager. See the\u001b[39m\u001b[K\r\n \u001b[33msandbox prerequisites: https://developers.openai.com/codex/concepts/sandboxing#prerequisites.\u001b[39m\u001b[K\r\n \u001b[33mCodex will use the bundled bubblewrap in the meantime.\u001b[39m\u001b[K\r\n\u001b[K\u001b[2m\r\n╭─────────────────────────────────────────────────╮\u001b(B\u001b[m\u001b[K\u001b[2m\r\n│ >_ \u001b(B\u001b[m\u001b[1mOpenAI Codex\u001b(B\u001b[m\u001b[2m (v0.147.0) │\u001b(B\u001b[m\u001b[K\u001b[2m\r\n│ │\u001b(B\u001b[m\u001b[K\u001b[2m\r\n│ model: \u001b(B\u001b[mgpt-5.6-sol\u001b[2m \u001b(B\u001b[m\u001b[36m/model\u001b[39m\u001b[2m to change │\u001b(B\u001b[m\u001b[K\u001b[2m\r\n│ directory: \u001b(B\u001b[m~/default/…/tmp/codexrec-work-ruT16A\u001b[2m │\u001b(B\u001b[m\u001b[K\u001b[2m\r\n╰─────────────────────────────────────────────────╯\u001b(B\u001b[m\u001b[K\r\n\u001b[K\r\n \u001b[1mTip:\u001b(B\u001b[m Our most capable model yet. GPT-5.6 Sol can tackle complex code changes, dig into research,\u001b[K\r\n produce polished documents, and take on your most ambitious work. Sol is highly capable at lower\u001b[K\r\n reasoning efforts—try starting lower, then turn it up for harder jobs.\u001b[K\r\n\u001b[K\r\n\u001b[K\u001b[1m\r\n›\u001b(B\u001b[m\u001b[1X\u001b[2m\u001b[CUse /skills to list available skills\u001b(B\u001b[m\u001b[K\r\n\u001b[K\u001b[20;2H\u001b[1K\u001b[38;5;223m\u001b[Cgpt-5.6-sol default\u001b[39m\u001b[2m · \u001b(B\u001b[m\u001b[38;5;151m~/default/claudeman-predictive/tmp/codexrec-work-ruT16A\u001b[39m\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\u001b[?12l\u001b[?25h\u001b[18;3H"}
|
||||||
|
{"keyAt":true,"data":"h"}
|
||||||
|
{"delayMs":199,"data":"h\u001b[K\u001b[20;80H\u001b[K\u001b[18;4H"}
|
||||||
|
{"keyAt":true,"data":"e"}
|
||||||
|
{"delayMs":40,"data":"e\u001b[K\u001b[20;80H\u001b[K\u001b[18;5H"}
|
||||||
|
{"keyAt":true,"data":"l"}
|
||||||
|
{"delayMs":40,"data":"l\u001b[K\u001b[20;80H\u001b[K\u001b[18;6H"}
|
||||||
|
{"keyAt":true,"data":"l"}
|
||||||
|
{"delayMs":40,"data":"l\u001b[K\u001b[20;80H\u001b[K\u001b[18;7H"}
|
||||||
|
{"keyAt":true,"data":"o"}
|
||||||
|
{"delayMs":40,"data":"o\u001b[K\u001b[20;80H\u001b[K\u001b[18;8H"}
|
||||||
|
{"keyAt":true,"data":"\r"}
|
||||||
|
{"delayMs":281,"data":"\u001b[16;30r\u001b[16;1H\u001bM\u001bM\u001bM\u001bM\u001b[1;30r\u001b[18;1H"}
|
||||||
|
{"delayMs":0,"data":"\u001b[1m\u001b[2m› \u001b(B\u001b[mhello\r\n"}
|
||||||
|
{"delayMs":0,"data":"\u001b[22;3H\u001b[2mUse /skills to list available skills\u001b(B\u001b[m\u001b[K\u001b[24;80H\u001b[K\u001b[22;3H"}
|
||||||
|
{"delayMs":12,"data":"\u001b[36C\u001b[K\u001b[24;80H\u001b[K\u001b[22;3H"}
|
||||||
|
{"delayMs":6,"data":"\u001b[36C\u001b[K\u001b[24;80H\u001b[K\u001b[22;3H"}
|
||||||
|
{"delayMs":6,"data":"\u001b[36C\u001b[K\u001b[24;80H\u001b[K\u001b[22;3H"}
|
||||||
|
{"delayMs":118,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"}
|
||||||
|
{"delayMs":0,"data":"\u001b[21;1H\u001b[J\u001b[A\u001b[K"}
|
||||||
|
{"delayMs":1,"data":"\r\n•\u001b[C\u001b[2mWorking\u001b[C(0s • esc to interrupt)\u001b[24;1H\u001b(B\u001b[m\u001b[1m›\u001b[C\u001b(B\u001b[m\u001b[2mUse /skills to list available skills\u001b[26;3H\u001b(B\u001b[m\u001b[38;5;223mgpt-5.6-sol default\u001b[39m\u001b[2m · \u001b(B\u001b[m\u001b[38;5;151m~/default/claudeman-predictive/tmp/codexrec-work-ruT16A\u001b[24;3H\u001b(B\u001b[m"}
|
||||||
|
{"delayMs":33,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"}
|
||||||
|
{"delayMs":0,"data":"\u001b[21;34H\u001b[K\u001b[24;39H\u001b[K\u001b[26;80H\u001b[K\u001b[24;3H"}
|
||||||
|
{"delayMs":33,"data":"\u001b[21;34H\u001b[K\u001b[24;39H\u001b[K\u001b[26;80H\u001b[K\u001b[24;3H"}
|
||||||
|
{"delayMs":33,"data":"\u001b[21;34H\u001b[K\u001b[24;39H\u001b[K\u001b[26;80H\u001b[K\u001b[24;3H"}
|
||||||
|
{"delayMs":33,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"}
|
||||||
|
{"delayMs":0,"data":"\u001b[21;34H\u001b[K\u001b[24;39H\u001b[K\u001b[26;80H\u001b[K\u001b[24;3H"}
|
||||||
|
{"delayMs":34,"data":"\u001b[21;34H\u001b[K\u001b[24;39H\u001b[K\u001b[26;80H\u001b[K\u001b[24;3H"}
|
||||||
|
{"delayMs":33,"data":"\u001b[21;34H\u001b[K\u001b[24;39H\u001b[K\u001b[26;80H\u001b[K\u001b[24;3H"}
|
||||||
|
{"delayMs":34,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"}
|
||||||
|
{"delayMs":0,"data":"\u001b[21;34H\u001b[K\u001b[24;39H\u001b[K\u001b[26;80H\u001b[K\u001b[24;3H"}
|
||||||
|
{"delayMs":34,"data":"\u001b[21;34H\u001b[K\u001b[24;39H\u001b[K\u001b[26;80H\u001b[K\u001b[24;3H"}
|
||||||
|
{"delayMs":33,"data":"\u001b[21;34H\u001b[K\u001b[24;39H\u001b[K\u001b[26;80H\u001b[K\u001b[24;3H"}
|
||||||
|
{"delayMs":33,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"}
|
||||||
|
{"delayMs":0,"data":"\u001b[21;34H\u001b[K\u001b[24;39H\u001b[K\u001b[26;80H\u001b[K\u001b[24;3H"}
|
||||||
|
{"delayMs":33,"data":"\u001b[21;34H\u001b[K\u001b[24;39H\u001b[K\u001b[26;80H\u001b[K\u001b[24;3H"}
|
||||||
|
{"delayMs":33,"data":"\u001b[21;34H\u001b[K\u001b[24;39H\u001b[K\u001b[26;80H\u001b[K\u001b[24;3H"}
|
||||||
|
{"delayMs":33,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"}
|
||||||
|
{"delayMs":0,"data":"\u001b[21;34H\u001b[K\u001b[24;39H\u001b[K\u001b[26;80H\u001b[K\u001b[24;3H"}
|
||||||
|
{"delayMs":33,"data":"\u001b[21;34H\u001b[K\u001b[24;39H\u001b[K\u001b[26;80H\u001b[K\u001b[24;3H"}
|
||||||
|
{"delayMs":33,"data":"\u001b[21;34H\u001b[K\u001b[24;39H\u001b[K\u001b[26;80H\u001b[K\u001b[24;3H"}
|
||||||
|
{"delayMs":33,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"}
|
||||||
|
{"delayMs":0,"data":"\u001b[3AW\u001b[30C\u001b[K\u001b[24;39H\u001b[K\u001b[26;80H\u001b[K\u001b[24;3H"}
|
||||||
|
{"delayMs":33,"data":"\u001b[21;34H\u001b[K\u001b[24;39H\u001b[K\u001b[26;80H\u001b[K\u001b[24;3H"}
|
||||||
|
{"delayMs":33,"data":"\u001b[21;34H\u001b[K\u001b[24;39H\u001b[K\u001b[26;80H\u001b[K\u001b[21;1H\u001b[2m◦\u001b[C\u001b(B\u001b[m\u001b[1mW\u001b(B\u001b[mo\u001b[24;3H"}
|
||||||
|
{"delayMs":33,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"}
|
||||||
|
{"delayMs":0,"data":"\u001b[21;34H\u001b[K\u001b[24;39H\u001b[K\u001b[26;80H\u001b[K\u001b[24;3H"}
|
||||||
|
{"delayMs":33,"data":"\u001b[21;34H\u001b[K\u001b[24;39H\u001b[K\u001b[26;80H\u001b[K\u001b[24;3H"}
|
||||||
|
{"delayMs":33,"data":"\u001b[21;4H\u001b[1mo\u001b(B\u001b[mr\u001b[28C\u001b[K\u001b[24;39H\u001b[K\u001b[26;80H\u001b[K\u001b[24;3H"}
|
||||||
|
{"delayMs":33,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"}
|
||||||
|
{"delayMs":0,"data":"\u001b[21;34H\u001b[K\u001b[24;39H\u001b[K\u001b[26;80H\u001b[K\u001b[24;3H"}
|
||||||
|
{"delayMs":32,"data":"\u001b[21;5H\u001b[1mr\u001b(B\u001b[mk\u001b[27C\u001b[K\u001b[24;39H\u001b[K\u001b[26;80H\u001b[K\u001b[24;3H"}
|
||||||
|
{"delayMs":34,"data":"\u001b[21;34H\u001b[K\u001b[24;39H\u001b[K\u001b[26;80H\u001b[K\u001b[24;3H"}
|
||||||
|
{"delayMs":33,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"}
|
||||||
|
{"delayMs":0,"data":"\u001b[21;6H\u001b[1mk\u001b(B\u001b[mi\u001b[26C\u001b[K\u001b[24;39H\u001b[K\u001b[26;80H\u001b[K\u001b[24;3H"}
|
||||||
|
{"delayMs":33,"data":"\u001b[21;34H\u001b[K\u001b[24;39H\u001b[K\u001b[26;80H\u001b[K\u001b[24;3H"}
|
||||||
|
{"delayMs":33,"data":"\u001b[21;7H\u001b[1mi\u001b(B\u001b[mn\u001b[25C\u001b[K\u001b[24;39H\u001b[K\u001b[26;80H\u001b[K\u001b[24;3H"}
|
||||||
|
{"delayMs":33,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"}
|
||||||
|
{"delayMs":0,"data":"\u001b[21;34H\u001b[K\u001b[24;39H\u001b[K\u001b[26;80H\u001b[K\u001b[24;3H"}
|
||||||
|
{"delayMs":34,"data":"\u001b[3AW\u001b[4C\u001b[1mn\u001b(B\u001b[mg\u001b[24C\u001b[K\u001b[24;39H\u001b[K\u001b[26;80H\u001b[K\u001b[24;3H"}
|
||||||
|
{"delayMs":32,"data":"\u001b[21;34H\u001b[K\u001b[24;39H\u001b[K\u001b[26;80H\u001b[K\u001b[24;3H"}
|
||||||
|
{"delayMs":33,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"}
|
||||||
|
{"delayMs":0,"data":"\u001b[21;12H\u001b[2m1\u001b(B\u001b[m\u001b[21C\u001b[K\u001b[24;39H\u001b[K\u001b[26;80H\u001b[K\u001b[24;3H"}
|
||||||
|
{"delayMs":19,"data":"\u001b[21;1H\u001b[J\u001b[A\u001b[K"}
|
||||||
|
{"delayMs":1,"data":"\r\n\u001b[2m◦\u001b[CReconne\u001b(B\u001b[mc\u001b[1mting.\u001b(B\u001b[m.\u001b[2m. 2/5\u001b[C(1s • esc to interrupt)\r\n └ Unexpected status 401 Unauthorized: {\r\n \"error\": {\r\n \"message\": \"Incorre, url: wss://api.openai.com/v1/responses, cf-ray: a2831cf59baa039d-ZRH,…\u001b[27;1H\u001b(B\u001b[m\u001b[1m›\u001b[C\u001b(B\u001b[m\u001b[2mUse /skills to list available skills\u001b[29;3H\u001b(B\u001b[m\u001b[38;5;223mgpt-5.6-sol default\u001b[39m\u001b[2m · \u001b(B\u001b[m\u001b[38;5;151m~/default/claudeman-predictive/tmp/codexrec-work-ruT16A\u001b[27;3H\u001b(B\u001b[m"}
|
||||||
|
{"delayMs":33,"data":"\u001b[21;10H\u001b[2mc\u001b(B\u001b[mt\u001b[4C\u001b[1m.\u001b(B\u001b[m.\u001b[28C\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||||
|
{"delayMs":33,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||||
|
{"delayMs":33,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"}
|
||||||
|
{"delayMs":0,"data":"\u001b[21;11H\u001b[2mt\u001b(B\u001b[mi\u001b[4C\u001b[1m.\u001b(B\u001b[m \u001b[27C\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||||
|
{"delayMs":34,"data":"\u001b[21;12H\u001b[2mi\u001b(B\u001b[mn\u001b[4C\u001b[1m \u001b(B\u001b[m2\u001b[26C\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||||
|
{"delayMs":33,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"}
|
||||||
|
{"delayMs":0,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[21;1H•\u001b[27;3H"}
|
||||||
|
{"delayMs":34,"data":"\u001b[21;13H\u001b[2mn\u001b(B\u001b[mg\u001b[4C\u001b[1m2\u001b(B\u001b[m/\u001b[25C\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||||
|
{"delayMs":33,"data":"\u001b[21;14H\u001b[2mg\u001b(B\u001b[m.\u001b[4C\u001b[1m/\u001b(B\u001b[m5\u001b[24C\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||||
|
{"delayMs":36,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[?25l\u001b[?12l\u001b[?25h\u001b[27;3H"}
|
||||||
|
{"delayMs":31,"data":"\u001b[21;15H\u001b[2m.\u001b(B\u001b[m.\u001b[4C\u001b[1m5\u001b(B\u001b[m\u001b[24C\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||||
|
{"delayMs":32,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||||
|
{"delayMs":33,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"}
|
||||||
|
{"delayMs":0,"data":"\u001b[21;16H\u001b[2m.\u001b(B\u001b[m.\u001b[28C\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||||
|
{"delayMs":33,"data":"\u001b[21;17H\u001b[2m.\u001b(B\u001b[m \u001b[27C\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||||
|
{"delayMs":32,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||||
|
{"delayMs":35,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"}
|
||||||
|
{"delayMs":0,"data":"\u001b[21;18H\u001b[2m \u001b(B\u001b[m2\u001b[26C\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||||
|
{"delayMs":34,"data":"\u001b[21;19H\u001b[2m2\u001b(B\u001b[m/\u001b[25C\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||||
|
{"delayMs":33,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||||
|
{"delayMs":34,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"}
|
||||||
|
{"delayMs":0,"data":"\u001b[21;20H\u001b[2m/\u001b(B\u001b[m5\u001b[24C\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||||
|
{"delayMs":32,"data":"\u001b[21;21H\u001b[2m5\u001b(B\u001b[m\u001b[24C\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||||
|
{"delayMs":33,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||||
|
{"delayMs":33,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||||
|
{"delayMs":33,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"}
|
||||||
|
{"delayMs":0,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||||
|
{"delayMs":33,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||||
|
{"delayMs":33,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[21;1H\u001b[2m◦\u001b[27;3H\u001b(B\u001b[m"}
|
||||||
|
{"delayMs":34,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"}
|
||||||
|
{"delayMs":0,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||||
|
{"delayMs":21,"data":"\u001b[21;19H\u001b[2m3\u001b(B\u001b[m\u001b[26C\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;85H\u001b[2maca388822\u001b(B\u001b[m\u001b[6C\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||||
|
{"delayMs":33,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||||
|
{"delayMs":33,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"}
|
||||||
|
{"delayMs":0,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||||
|
{"delayMs":34,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||||
|
{"delayMs":34,"data":"\u001b[21;24H\u001b[2m2\u001b(B\u001b[m\u001b[21C\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||||
|
{"delayMs":33,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"}
|
||||||
|
{"delayMs":0,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||||
|
{"delayMs":33,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||||
|
{"delayMs":33,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||||
|
{"delayMs":33,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"}
|
||||||
|
{"delayMs":0,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||||
|
{"delayMs":34,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||||
|
{"delayMs":34,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||||
|
{"delayMs":33,"data":"\u001b[?25l\u001b[?12l\u001b[?25h\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||||
|
{"delayMs":34,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||||
|
{"delayMs":33,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||||
|
{"delayMs":33,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"}
|
||||||
|
{"delayMs":0,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||||
|
{"delayMs":34,"data":"\u001b[6AR\u001b[42C\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||||
|
{"delayMs":33,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[21;1H•\u001b[27;3H"}
|
||||||
|
{"delayMs":34,"data":"\u001b[6A\u001b[1mR\u001b(B\u001b[me\u001b[41C\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||||
|
{"delayMs":0,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"}
|
||||||
|
{"delayMs":33,"data":"\u001b[21;4H\u001b[1me\u001b(B\u001b[mc\u001b[40C\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||||
|
{"delayMs":33,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||||
|
{"delayMs":32,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"}
|
||||||
|
{"delayMs":0,"data":"\u001b[21;5H\u001b[1mc\u001b(B\u001b[mo\u001b[39C\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||||
|
{"delayMs":34,"data":"\u001b[21;6H\u001b[1mo\u001b(B\u001b[mn\u001b[38C\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||||
|
{"delayMs":33,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||||
|
{"delayMs":33,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"}
|
||||||
|
{"delayMs":0,"data":"\u001b[21;7H\u001b[1mn\u001b(B\u001b[mn\u001b[37C\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||||
|
{"delayMs":33,"data":"\u001b[6AR\u001b[4C\u001b[1mn\u001b(B\u001b[me\u001b[36C\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||||
|
{"delayMs":33,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||||
|
{"delayMs":33,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"}
|
||||||
|
{"delayMs":0,"data":"\u001b[6A\u001b[2mR\u001b(B\u001b[me\u001b[4C\u001b[1me\u001b(B\u001b[mc\u001b[35C\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||||
|
{"delayMs":34,"data":"\u001b[21;4H\u001b[2me\u001b(B\u001b[mc\u001b[4C\u001b[1mc\u001b(B\u001b[mt\u001b[34C\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||||
|
{"delayMs":34,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||||
|
{"delayMs":33,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"}
|
||||||
|
{"delayMs":0,"data":"\u001b[21;5H\u001b[2mc\u001b(B\u001b[mo\u001b[4C\u001b[1mt\u001b(B\u001b[mi\u001b[33C\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||||
|
{"delayMs":33,"data":"\u001b[21;6H\u001b[2mo\u001b(B\u001b[mn\u001b[4C\u001b[1mi\u001b(B\u001b[mn\u001b[32C\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||||
|
{"delayMs":34,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||||
|
{"delayMs":33,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"}
|
||||||
|
{"delayMs":0,"data":"\u001b[21;7H\u001b[2mn\u001b(B\u001b[mn\u001b[4C\u001b[1mn\u001b(B\u001b[mg\u001b[31C\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||||
|
{"delayMs":33,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||||
|
{"delayMs":33,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[21;1H\u001b[2m◦\u001b[6Cn\u001b(B\u001b[me\u001b[4C\u001b[1mg\u001b(B\u001b[m.\u001b[8C\u001b[2m3\u001b[27;3H\u001b(B\u001b[m"}
|
||||||
|
{"delayMs":34,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"}
|
||||||
|
{"delayMs":0,"data":"\u001b[21;9H\u001b[2me\u001b(B\u001b[mc\u001b[4C\u001b[1m.\u001b(B\u001b[m.\u001b[29C\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||||
|
{"delayMs":34,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||||
|
{"delayMs":33,"data":"\u001b[21;10H\u001b[2mc\u001b(B\u001b[mt\u001b[4C\u001b[1m.\u001b(B\u001b[m.\u001b[28C\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;100H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||||
|
{"delayMs":25,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"}
|
||||||
|
{"delayMs":0,"data":"\u001b[21;11H\u001b[2mt\u001b(B\u001b[mi\u001b[4C\u001b[1m.\u001b(B\u001b[m \u001b[2m4\u001b(B\u001b[m\u001b[26C\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;27H\u001b[2m, url: ws\u001b[C:/\u001b[Capi.openai.com/v1/responses, cf-ray: a2831d0298dca625-ZRH,\u001b(B\u001b[m\u001b[C\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[24;98H\u001b[2m…\u001b[27;3H\u001b(B\u001b[m"}
|
||||||
|
{"delayMs":33,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;99H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||||
|
{"delayMs":33,"data":"\u001b[21;12H\u001b[2mi\u001b(B\u001b[mn\u001b[4C\u001b[1m \u001b(B\u001b[m4\u001b[26C\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;99H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||||
|
{"delayMs":33,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"}
|
||||||
|
{"delayMs":0,"data":"\u001b[21;13H\u001b[2mn\u001b(B\u001b[mg\u001b[4C\u001b[1m4\u001b(B\u001b[m/\u001b[25C\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;99H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||||
|
{"delayMs":34,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;99H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||||
|
{"delayMs":34,"data":"\u001b[21;14H\u001b[2mg\u001b(B\u001b[m.\u001b[4C\u001b[1m/\u001b(B\u001b[m5\u001b[24C\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;99H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||||
|
{"delayMs":33,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"}
|
||||||
|
{"delayMs":0,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;99H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||||
|
{"delayMs":34,"data":"\u001b[21;15H\u001b[2m.\u001b(B\u001b[m.\u001b[4C\u001b[1m5\u001b(B\u001b[m\u001b[24C\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;99H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||||
|
{"delayMs":32,"data":"\u001b[21;16H\u001b[2m.\u001b(B\u001b[m.\u001b[28C\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;99H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||||
|
{"delayMs":33,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"}
|
||||||
|
{"delayMs":0,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;99H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||||
|
{"delayMs":33,"data":"\u001b[21;17H\u001b[2m.\u001b(B\u001b[m \u001b[27C\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;99H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||||
|
{"delayMs":34,"data":"\u001b[21;18H\u001b[2m \u001b(B\u001b[m4\u001b[26C\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;99H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||||
|
{"delayMs":33,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"}
|
||||||
|
{"delayMs":0,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;99H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||||
|
{"delayMs":34,"data":"\u001b[21;19H\u001b[2m4\u001b(B\u001b[m/\u001b[25C\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;99H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||||
|
{"delayMs":33,"data":"\u001b[21;20H\u001b[2m/\u001b(B\u001b[m5\u001b[24C\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;99H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||||
|
{"delayMs":33,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"}
|
||||||
|
{"delayMs":0,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;99H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[21;1H•\u001b[27;3H"}
|
||||||
|
{"delayMs":34,"data":"\u001b[21;21H\u001b[2m5\u001b(B\u001b[m\u001b[24C\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;99H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||||
|
{"delayMs":34,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;99H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||||
|
{"delayMs":32,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"}
|
||||||
|
{"delayMs":0,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;99H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||||
|
{"delayMs":34,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;99H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||||
|
{"delayMs":33,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;99H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||||
|
{"delayMs":33,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"}
|
||||||
|
{"delayMs":1,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;99H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||||
|
{"delayMs":32,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;99H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||||
|
{"delayMs":34,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;99H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||||
|
{"delayMs":33,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"}
|
||||||
|
{"delayMs":0,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;99H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||||
|
{"delayMs":34,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;99H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||||
|
{"delayMs":32,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;99H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||||
|
{"delayMs":33,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"}
|
||||||
|
{"delayMs":0,"data":"\u001b[21;24H\u001b[2m4\u001b(B\u001b[m\u001b[21C\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;99H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||||
|
{"delayMs":34,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;99H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||||
|
{"delayMs":34,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;99H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||||
|
{"delayMs":33,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"}
|
||||||
|
{"delayMs":0,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;99H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||||
|
{"delayMs":34,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;99H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||||
|
{"delayMs":34,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;99H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||||
|
{"delayMs":33,"data":"\u001b[?25l\u001b[?12l\u001b[?25h\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;99H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[21;1H\u001b[2m◦\u001b[27;3H\u001b(B\u001b[m"}
|
||||||
|
{"delayMs":34,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;99H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||||
|
{"delayMs":34,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;99H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||||
|
{"delayMs":33,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"}
|
||||||
|
{"delayMs":0,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;99H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||||
|
{"delayMs":33,"data":"\u001b[6AR\u001b[42C\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;99H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||||
|
{"delayMs":33,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;99H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||||
|
{"delayMs":32,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"}
|
||||||
|
{"delayMs":0,"data":"\u001b[6A\u001b[1mR\u001b(B\u001b[me\u001b[41C\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;99H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||||
|
{"delayMs":34,"data":"\u001b[21;4H\u001b[1me\u001b(B\u001b[mc\u001b[40C\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;99H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||||
|
{"delayMs":33,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;99H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||||
|
{"delayMs":33,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"}
|
||||||
|
{"delayMs":0,"data":"\u001b[21;5H\u001b[1mc\u001b(B\u001b[mo\u001b[39C\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;99H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||||
|
{"delayMs":34,"data":"\u001b[21;6H\u001b[1mo\u001b(B\u001b[mn\u001b[38C\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;99H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||||
|
{"delayMs":33,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;99H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||||
|
{"delayMs":33,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"}
|
||||||
|
{"delayMs":0,"data":"\u001b[21;7H\u001b[1mn\u001b(B\u001b[mn\u001b[37C\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;99H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||||
|
{"delayMs":34,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;99H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||||
|
{"delayMs":34,"data":"\u001b[6AR\u001b[4C\u001b[1mn\u001b(B\u001b[me\u001b[36C\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;99H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||||
|
{"delayMs":33,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"}
|
||||||
|
{"delayMs":0,"data":"\u001b[6A\u001b[2mR\u001b(B\u001b[me\u001b[4C\u001b[1me\u001b(B\u001b[mc\u001b[35C\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;99H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||||
|
{"delayMs":34,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;99H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||||
|
{"delayMs":33,"data":"\u001b[21;46H\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;99H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[21;1H•\u001b[2C\u001b[2me\u001b(B\u001b[mc\u001b[4C\u001b[1mc\u001b(B\u001b[mt\u001b[27;3H"}
|
||||||
|
{"delayMs":32,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"}
|
||||||
|
{"delayMs":0,"data":"\u001b[21;5H\u001b[2mc\u001b(B\u001b[mo\u001b[4C\u001b[1mt\u001b(B\u001b[mi\u001b[33C\u001b[K\u001b[22;42H\u001b[K\u001b[23;17H\u001b[K\u001b[24;99H\u001b[K\u001b[27;39H\u001b[K\u001b[29;80H\u001b[K\u001b[27;3H"}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
{"scenario":"streaming-real","cols":100,"rows":30,"codexVersion":"codex-cli 0.147.0","recordedAt":"2026-08-09T09:31:57.351Z"}
|
||||||
|
{"delayMs":0,"data":"\u001b[22;0;0t\u001b[?1h\u001b=\u001b[H\u001b[2J\u001b[?12l\u001b[?25h\u001b[?2004h\u001b(B\u001b[m\u001b[?12l\u001b[?25h\u001b[1;1H\u001b[1;30r\u001b[c\u001b[>c\u001b[>q\u001b]10;?\u001b\\\u001b]11;?\u001b\\\u001b[1;1H"}
|
||||||
|
{"delayMs":1,"data":"\u001b[?25l\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\u001b[?12l\u001b[?25h\u001b[H\u001b(B\u001b[m\u001b[?12l\u001b[?25h\u001b[1;1H\u001b[1;30r\u001b[1;1H\u001b[?25l\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\u001b[?12l\u001b[?25h\u001b[H"}
|
||||||
|
{"delayMs":35,"data":"\u001b[32m\u001b[1markon@tnode\u001b(B\u001b[m:\u001b[34m\u001b[1m~/default/claudeman-predictive/tmp/codexrec-work-SFpno1\u001b(B\u001b[m$ "}
|
||||||
|
{"delayMs":647,"data":"exec codex\r\n"}
|
||||||
|
{"delayMs":479,"data":"\u001b[30d\n\u001b[K\u001b[2d\u001b[J\u001b[H\u001b[K\u001b[?25l\u001b[?12l\u001b[?25h"}
|
||||||
|
{"delayMs":2,"data":">\u001b[C\u001b[1mYou are in \u001b(B\u001b[m/home/arkon/default/claudeman-predictive/tmp/codexrec-work-SFpno1\u001b[3;3H\u001b[33mNote: You’re in a subdirectory of a Git project. Trusting will apply to the repository root:\u001b[4;3H/home/arkon/default/claudeman\u001b[6;3H\u001b[39mDo\u001b[Cyou\u001b[Ctrust\u001b[Cthe\u001b[Ccontents\u001b[Cof\u001b[Cthis\u001b[Cdirectory?\u001b[CWorking\u001b[Cwith\u001b[Cuntrusted\u001b[Ccontents\u001b[Ccomes\u001b[Cwith\u001b[Chigher\u001b[7;3Hrisk\u001b[Cof\u001b[Cprompt\u001b[Cinjection.\u001b[CTrusting\u001b[Cthe\u001b[Cdirectory\u001b[Callows\u001b[Cproject-local\u001b[Cconfig,\u001b[Chooks,\u001b[Cand\u001b[Cexec\u001b[8;3Hpolicies\u001b[Cto\u001b[Cload.\u001b[10;1H\u001b[36m› 1. Yes, continue\u001b[11;3H\u001b[39m2.\u001b[CNo,\u001b[Cquit\u001b[13;3H\u001b[2mPress enter to continue\u001b[?25l\u001b(B\u001b[m"}
|
||||||
|
{"delayMs":3830,"data":"\u001b[?7727h\u001b(B\u001b[m\u001b[?12l\u001b[?25h\u001b[1;1H\u001b[1;30r\u001b[13;26H\u001b[?25l"}
|
||||||
|
{"delayMs":1,"data":"\u001b[H>\u001b[1X\u001b[1m\u001b[CYou are in \u001b(B\u001b[m/home/arkon/default/claudeman-predictive/tmp/codexrec-work-SFpno1\u001b[K\r\n\u001b[K\u001b[3;2H\u001b[1K\u001b[33m\u001b[CNote: You’re in a subdirectory of a Git project. Trusting will apply to the repository root:\u001b[39m\u001b[K\u001b[4;2H\u001b[1K\u001b[33m\u001b[C/home/arkon/default/claudeman\u001b[39m\u001b[K\r\n\u001b[K\u001b[6;2H\u001b[1K\u001b[CDo\u001b[1X\u001b[Cyou\u001b[1X\u001b[Ctrust\u001b[1X\u001b[Cthe\u001b[1X\u001b[Ccontents\u001b[1X\u001b[Cof\u001b[1X\u001b[Cthis\u001b[1X\u001b[Cdirectory?\u001b[1X\u001b[CWorking\u001b[1X\u001b[Cwith\u001b[1X\u001b[Cuntrusted\u001b[1X\u001b[Ccontents\u001b[1X\u001b[Ccomes\u001b[1X\u001b[Cwith\u001b[1X\u001b[Chigher\u001b[K\u001b[7;2H\u001b[1K\u001b[Crisk\u001b[1X\u001b[Cof\u001b[1X\u001b[Cprompt\u001b[1X\u001b[Cinjection.\u001b[1X\u001b[CTrusting\u001b[1X\u001b[Cthe\u001b[1X\u001b[Cdirectory\u001b[1X\u001b[Callows\u001b[1X\u001b[Cproject-local\u001b[1X\u001b[Cconfig,\u001b[1X\u001b[Chooks,\u001b[1X\u001b[Cand\u001b[1X\u001b[Cexec\u001b[K\u001b[8;2H\u001b[1K\u001b[Cpolicies\u001b[1X\u001b[Cto\u001b[1X\u001b[Cload.\u001b[K\r\n\u001b[K\u001b[36m\r\n› 1. Yes, continue\u001b[39m\u001b[K\u001b[11;2H\u001b[1K\u001b[C2.\u001b[1X\u001b[CNo,\u001b[1X\u001b[Cquit\u001b[K\r\n\u001b[K\u001b[13;2H\u001b[1K\u001b[2m\u001b[CPress enter to continue\u001b(B\u001b[m\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\u001b[13;26H"}
|
||||||
|
{"keyAt":true,"data":"\r"}
|
||||||
|
{"delayMs":235,"data":"\u001b[2;1H\u001b[J\u001b[H\u001b[K"}
|
||||||
|
{"delayMs":0,"data":"\u001bM\u001bM\u001bM\r\n\u001b[33m⚠\u001b[39m\u001b[1;3r\u001b[3;1H\n\u001b[1;2H\u001b[33m Codex could not find bubblewrap on PATH. Install bubblewrap with your OS package manager. See the\r\n\u001b[39m \u001b[33msandbox prerequisites: https://developers.openai.com/codex/concepts/sandboxing#prerequisites.\u001b[39m\r\n\u001b[K\u001b[1;30r\u001b[3;1H"}
|
||||||
|
{"delayMs":1,"data":" \u001b[33mCodex will use the bundled bubblewrap in the meantime.\u001b[5;1H\u001b[39m\u001b[2m╭─────────────────────────────────────────────────╮\r\n│ >_ \u001b(B\u001b[m\u001b[1mOpenAI Codex\u001b(B\u001b[m\u001b[2m (v0.147.0) │\r\n│ │\r\n│ model: \u001b[3mloading\u001b(B\u001b[m\u001b[2m \u001b(B\u001b[m\u001b[36m/model\u001b[39m\u001b[2m to change │\r\n│ directory: \u001b(B\u001b[m~/default/…/tmp/codexrec-work-SFpno1\u001b[2m │\r\n╰─────────────────────────────────────────────────╯\u001b[13;1H\u001b(B\u001b[m\u001b[1m›\u001b[C\u001b(B\u001b[m\u001b[2mUse /skills t\u001b(B\u001b[m\u001b[2mo list available skills\u001b[15;3H\u001b(B\u001b[m\u001b[38;5;223mgpt-5.6-terra default\u001b[39m\u001b[2m · \u001b(B\u001b[m\u001b[38;5;151m~/default/claudeman-predictive/tmp/codexrec-work-SFpno1\u001b[13;3H\u001b[?12l\u001b[?25h\u001b(B\u001b[m"}
|
||||||
|
{"delayMs":10,"data":"\u001b[5;52H\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\u001b[13;39H\u001b[K\u001b[15;82H\u001b[K\u001b[13;3H"}
|
||||||
|
{"delayMs":12,"data":"\u001b[5;52H\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\u001b[13;39H\u001b[K\u001b[15;82H\u001b[K\u001b[13;3H"}
|
||||||
|
{"delayMs":208,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"}
|
||||||
|
{"delayMs":0,"data":"\u001b[5;1H\u001b[J\u001b[A\u001b[K\u001b[4;30r\u001b[4;1H\u001bM\u001bM\u001bM\u001bM\u001bM\u001bM\u001bM\u001bM\u001bM\u001b[1;30r\u001b[5;1H"}
|
||||||
|
{"delayMs":0,"data":"\u001b[2m╭─────────────────────────────────────────────────╮\r\n│ >_ \u001b(B\u001b[m\u001b[1mOpenAI Codex\u001b(B\u001b[m\u001b[2m (v0.147.0) │\r\n│ │\r\n\u001b(B\u001b[m"}
|
||||||
|
{"delayMs":0,"data":"\u001b[2m│ model: \u001b(B\u001b[mgpt-5.6-terra\u001b[2m \u001b(B\u001b[m\u001b[36m/model\u001b[39m\u001b[2m to change │\r\n│ directory: \u001b(B\u001b[m~/default/…/tmp/codexrec-work-SFpno1\u001b[2m │\r\n╰─────────────────────────────────────────────────╯\u001b[12;1H\u001b(B\u001b[m"}
|
||||||
|
{"delayMs":0,"data":" \u001b[1mTip:\u001b(B\u001b[m \u001b[3mNew\u001b(B\u001b[m For a limited time, Codex is included in your plan for free – let’s build together.\u001b[14;1H•\u001b[C\u001b[2mBooting MCP server: codex_apps\u001b[C(0s • esc to interrupt)\u001b[17;1H\u001b(B\u001b[m\u001b[1m›\u001b[C\u001b(B\u001b[m\u001b[2mUse /skills to list available skills\u001b[19;3H\u001b(B\u001b[m\u001b[38;5;223mgpt-5.6-terra default\u001b[39m\u001b[2m · \u001b(B\u001b[m\u001b[38;5;151m~/default/claudeman-predictive/tmp/codexrec-work-SFpno1\u001b[17;3H\u001b(B\u001b[m"}
|
||||||
|
{"delayMs":0,"data":"\u001b[14;57H\u001b[K\u001b[17;39H\u001b[K\u001b[19;82H\u001b[K\u001b[17;3H"}
|
||||||
|
{"delayMs":19,"data":"\u001b[14;57H\u001b[K\u001b[17;39H\u001b[K\u001b[19;82H\u001b[K\u001b[17;3H"}
|
||||||
|
{"delayMs":1,"data":"\u001b[14;57H\u001b[K\u001b[17;39H\u001b[K\u001b[19;82H\u001b[K\u001b[17;3H"}
|
||||||
|
{"delayMs":33,"data":"\u001b[14;57H\u001b[K\u001b[17;39H\u001b[K\u001b[19;82H\u001b[K\u001b[17;3H"}
|
||||||
|
{"delayMs":33,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"}
|
||||||
|
{"delayMs":1,"data":"\u001b[14;57H\u001b[K\u001b[17;39H\u001b[K\u001b[19;82H\u001b[K\u001b[17;3H"}
|
||||||
|
{"delayMs":33,"data":"\u001b[14;57H\u001b[K\u001b[17;39H\u001b[K\u001b[19;82H\u001b[K\u001b[17;3H"}
|
||||||
|
{"delayMs":33,"data":"\u001b[14;57H\u001b[K\u001b[17;39H\u001b[K\u001b[19;82H\u001b[K\u001b[17;3H"}
|
||||||
|
{"delayMs":34,"data":"\u001b[?25l\u001b[?12l\u001b[?25h\u001b[14;57H\u001b[K\u001b[17;39H\u001b[K\u001b[19;82H\u001b[K\u001b[17;3H"}
|
||||||
|
{"delayMs":28,"data":"\u001b[14;57H\u001b[K\u001b[17;39H\u001b[K\u001b[19;82H\u001b[K\u001b[17;3H"}
|
||||||
|
{"delayMs":34,"data":"\u001b[14;57H\u001b[K\u001b[17;39H\u001b[K\u001b[19;82H\u001b[K\u001b[17;3H"}
|
||||||
|
{"delayMs":33,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"}
|
||||||
|
{"delayMs":0,"data":"\u001b[3AB\u001b[53C\u001b[K\u001b[17;39H\u001b[K\u001b[19;82H\u001b[K\u001b[17;3H"}
|
||||||
|
{"delayMs":2,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"}
|
||||||
|
{"delayMs":5,"data":"\u001b[14;1H\u001b[J\u001b[A\u001b[K"}
|
||||||
|
{"delayMs":1,"data":"\u001b[2B\u001b[1m›\u001b[C\u001b(B\u001b[m\u001b[2mUse /skills to list available skills\u001b[17;3H\u001b(B\u001b[m\u001b[38;5;223mgpt-5.6-terra default\u001b[39m\u001b[2m · \u001b(B\u001b[m\u001b[38;5;151m~/default/claudeman-predictive/tmp/codexrec-work-SFpno1\u001b[15;3H\u001b(B\u001b[m"}
|
||||||
|
{"delayMs":279,"data":"\u001b[36C\u001b[K\u001b[17;82H\u001b[K\u001b[15;3H"}
|
||||||
|
{"delayMs":86,"data":"\u001b[36C\u001b[K\u001b[17;82H\u001b[K\u001b[15;3H"}
|
||||||
|
{"delayMs":71,"data":"\u001b[36C\u001b[K\u001b[17;82H\u001b[K\u001b[15;3H"}
|
||||||
|
{"keyAt":true,"data":"r"}
|
||||||
|
{"keyAt":true,"data":"e"}
|
||||||
|
{"keyAt":true,"data":"p"}
|
||||||
|
{"keyAt":true,"data":"l"}
|
||||||
|
{"keyAt":true,"data":"y"}
|
||||||
|
{"keyAt":true,"data":" "}
|
||||||
|
{"keyAt":true,"data":"w"}
|
||||||
|
{"keyAt":true,"data":"i"}
|
||||||
|
{"keyAt":true,"data":"t"}
|
||||||
|
{"keyAt":true,"data":"h"}
|
||||||
|
{"keyAt":true,"data":" "}
|
||||||
|
{"keyAt":true,"data":"t"}
|
||||||
|
{"keyAt":true,"data":"h"}
|
||||||
|
{"keyAt":true,"data":"e"}
|
||||||
|
{"keyAt":true,"data":" "}
|
||||||
|
{"keyAt":true,"data":"s"}
|
||||||
|
{"keyAt":true,"data":"i"}
|
||||||
|
{"keyAt":true,"data":"n"}
|
||||||
|
{"keyAt":true,"data":"g"}
|
||||||
|
{"keyAt":true,"data":"l"}
|
||||||
|
{"keyAt":true,"data":"e"}
|
||||||
|
{"keyAt":true,"data":" "}
|
||||||
|
{"keyAt":true,"data":"w"}
|
||||||
|
{"keyAt":true,"data":"o"}
|
||||||
|
{"keyAt":true,"data":"r"}
|
||||||
|
{"keyAt":true,"data":"d"}
|
||||||
|
{"keyAt":true,"data":" "}
|
||||||
|
{"keyAt":true,"data":"h"}
|
||||||
|
{"keyAt":true,"data":"e"}
|
||||||
|
{"keyAt":true,"data":"l"}
|
||||||
|
{"keyAt":true,"data":"l"}
|
||||||
|
{"keyAt":true,"data":"o"}
|
||||||
|
{"delayMs":2010,"data":"reply with the single word hello\u001b[K\u001b[17;82H\u001b[K\u001b[15;35H"}
|
||||||
|
{"keyAt":true,"data":"\r"}
|
||||||
|
{"delayMs":382,"data":"\u001b[13;30r\u001b[13;1H\u001bM\u001bM\u001bM\u001bM\u001b[1;30r\u001b[15;1H"}
|
||||||
|
{"delayMs":0,"data":"\u001b[1m\u001b[2m› \u001b(B\u001b[mreply with the single word hello\r\n"}
|
||||||
|
{"delayMs":0,"data":"\u001b[19;3H\u001b[2mUse /skills to list available skills\u001b(B\u001b[m\u001b[K\u001b[21;82H\u001b[K\u001b[19;3H"}
|
||||||
|
{"delayMs":0,"data":"\u001b[36C\u001b[K\u001b[21;82H\u001b[K\u001b[19;3H"}
|
||||||
|
{"delayMs":20,"data":"\u001b[36C\u001b[K\u001b[21;82H\u001b[K\u001b[19;3H"}
|
||||||
|
{"delayMs":8,"data":"\u001b[36C\u001b[K\u001b[21;82H\u001b[K\u001b[19;3H"}
|
||||||
|
{"delayMs":78,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"}
|
||||||
|
{"delayMs":0,"data":"\u001b[18;1H\u001b[J\u001b[A\u001b[K"}
|
||||||
|
{"delayMs":0,"data":"\r\n•\u001b[C\u001b[2mWor\u001b(B\u001b[mk\u001b[1ming\u001b[C\u001b(B\u001b[m\u001b[2m(0s • esc to interrupt)\u001b[21;1H\u001b(B\u001b[m\u001b[1m›\u001b[C\u001b(B\u001b[m\u001b[2mUse /skills to list available skills\u001b[23;3H\u001b(B\u001b[m\u001b[38;5;223mgpt-5.6-terra default\u001b[39m\u001b[2m · \u001b(B\u001b[m\u001b[38;5;151m~/default/claudeman-predictive/tmp/codexrec-work-SFpno1\u001b[21;3H\u001b(B\u001b[m"}
|
||||||
|
{"delayMs":33,"data":"\u001b[18;34H\u001b[K\u001b[21;39H\u001b[K\u001b[23;82H\u001b[K\u001b[21;3H"}
|
||||||
|
{"delayMs":33,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"}
|
||||||
|
{"delayMs":0,"data":"\u001b[18;6H\u001b[2mk\u001b(B\u001b[mi\u001b[26C\u001b[K\u001b[21;39H\u001b[K\u001b[23;82H\u001b[K\u001b[21;3H"}
|
||||||
|
{"delayMs":34,"data":"\u001b[18;34H\u001b[K\u001b[21;39H\u001b[K\u001b[23;82H\u001b[K\u001b[21;3H"}
|
||||||
|
{"delayMs":35,"data":"\u001b[18;7H\u001b[2mi\u001b(B\u001b[mn\u001b[25C\u001b[K\u001b[21;39H\u001b[K\u001b[23;82H\u001b[K\u001b[21;3H"}
|
||||||
|
{"delayMs":33,"data":"\u001b[?25l\u001b[?12l\u001b[?25h\u001b[18;34H\u001b[K\u001b[21;39H\u001b[K\u001b[23;82H\u001b[K\u001b[21;3H"}
|
||||||
|
{"delayMs":33,"data":"\u001b[18;8H\u001b[2mn\u001b(B\u001b[mg\u001b[24C\u001b[K\u001b[21;39H\u001b[K\u001b[23;82H\u001b[K\u001b[21;3H"}
|
||||||
|
{"delayMs":33,"data":"\u001b[18;34H\u001b[K\u001b[21;39H\u001b[K\u001b[23;82H\u001b[K\u001b[21;3H"}
|
||||||
|
{"delayMs":33,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"}
|
||||||
|
{"delayMs":0,"data":"\u001b[18;9H\u001b[2mg\u001b(B\u001b[m\u001b[24C\u001b[K\u001b[21;39H\u001b[K\u001b[23;82H\u001b[K\u001b[21;3H"}
|
||||||
|
{"delayMs":34,"data":"\u001b[18;34H\u001b[K\u001b[21;39H\u001b[K\u001b[23;82H\u001b[K\u001b[21;3H"}
|
||||||
|
{"delayMs":34,"data":"\u001b[18;34H\u001b[K\u001b[21;39H\u001b[K\u001b[23;82H\u001b[K\u001b[21;3H"}
|
||||||
|
{"delayMs":33,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"}
|
||||||
|
{"delayMs":0,"data":"\u001b[18;34H\u001b[K\u001b[21;39H\u001b[K\u001b[23;82H\u001b[K\u001b[21;3H"}
|
||||||
|
{"delayMs":33,"data":"\u001b[18;34H\u001b[K\u001b[21;39H\u001b[K\u001b[23;82H\u001b[K\u001b[21;3H"}
|
||||||
|
{"delayMs":33,"data":"\u001b[18;34H\u001b[K\u001b[21;39H\u001b[K\u001b[23;82H\u001b[K\u001b[21;3H"}
|
||||||
|
{"delayMs":33,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"}
|
||||||
|
{"delayMs":0,"data":"\u001b[18;34H\u001b[K\u001b[21;39H\u001b[K\u001b[23;82H\u001b[K\u001b[21;3H"}
|
||||||
|
{"delayMs":33,"data":"\u001b[18;34H\u001b[K\u001b[21;39H\u001b[K\u001b[23;82H\u001b[K\u001b[21;3H"}
|
||||||
|
{"delayMs":33,"data":"\u001b[18;34H\u001b[K\u001b[21;39H\u001b[K\u001b[23;82H\u001b[K\u001b[21;3H"}
|
||||||
|
{"delayMs":33,"data":"\u001b[?25l\u001b[?12l\u001b[?25h\u001b[18;34H\u001b[K\u001b[21;39H\u001b[K\u001b[23;82H\u001b[K\u001b[21;3H"}
|
||||||
|
{"delayMs":33,"data":"\u001b[18;34H\u001b[K\u001b[21;39H\u001b[K\u001b[23;82H\u001b[K\u001b[18;1H\u001b[2m◦\u001b[21;3H\u001b(B\u001b[m"}
|
||||||
|
{"delayMs":34,"data":"\u001b[18;34H\u001b[K\u001b[21;39H\u001b[K\u001b[23;82H\u001b[K\u001b[21;3H"}
|
||||||
|
{"delayMs":33,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"}
|
||||||
|
{"delayMs":0,"data":"\u001b[18;34H\u001b[K\u001b[21;39H\u001b[K\u001b[23;82H\u001b[K\u001b[21;3H"}
|
||||||
|
{"delayMs":33,"data":"\u001b[18;34H\u001b[K\u001b[21;39H\u001b[K\u001b[23;82H\u001b[K\u001b[21;3H"}
|
||||||
|
{"delayMs":32,"data":"\u001b[18;34H\u001b[K\u001b[21;39H\u001b[K\u001b[23;82H\u001b[K\u001b[21;3H"}
|
||||||
|
{"delayMs":33,"data":"\u001b[?25l\u001b[?12l\u001b[?25h\u001b[18;34H\u001b[K\u001b[21;39H\u001b[K\u001b[23;82H\u001b[K\u001b[21;3H"}
|
||||||
|
{"delayMs":33,"data":"\u001b[18;34H\u001b[K\u001b[21;39H\u001b[K\u001b[23;82H\u001b[K\u001b[21;3H"}
|
||||||
|
{"delayMs":34,"data":"\u001b[18;34H\u001b[K\u001b[21;39H\u001b[K\u001b[23;82H\u001b[K\u001b[21;3H"}
|
||||||
|
{"delayMs":33,"data":"\u001b[?25l\u001b[?12l\u001b[?25h\u001b[18;34H\u001b[K\u001b[21;39H\u001b[K\u001b[23;82H\u001b[K\u001b[21;3H"}
|
||||||
|
{"delayMs":34,"data":"\u001b[18;34H\u001b[K\u001b[21;39H\u001b[K\u001b[23;82H\u001b[K\u001b[21;3H"}
|
||||||
|
{"delayMs":33,"data":"\u001b[18;34H\u001b[K\u001b[21;39H\u001b[K\u001b[23;82H\u001b[K\u001b[21;3H"}
|
||||||
|
{"delayMs":33,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"}
|
||||||
|
{"delayMs":0,"data":"\u001b[18;34H\u001b[K\u001b[21;39H\u001b[K\u001b[23;82H\u001b[K\u001b[21;3H"}
|
||||||
|
{"delayMs":33,"data":"\u001b[18;12H\u001b[2m1\u001b(B\u001b[m\u001b[21C\u001b[K\u001b[21;39H\u001b[K\u001b[23;82H\u001b[K\u001b[21;3H"}
|
||||||
|
{"delayMs":33,"data":"\u001b[18;34H\u001b[K\u001b[21;39H\u001b[K\u001b[23;82H\u001b[K\u001b[21;3H"}
|
||||||
|
{"delayMs":33,"data":"\u001b[18;34H\u001b[K\u001b[21;39H\u001b[K\u001b[23;82H\u001b[K\u001b[21;3H"}
|
||||||
|
{"delayMs":0,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"}
|
||||||
|
{"delayMs":33,"data":"\u001b[18;34H\u001b[K\u001b[21;39H\u001b[K\u001b[23;82H\u001b[K\u001b[21;3H"}
|
||||||
|
{"delayMs":34,"data":"\u001b[18;34H\u001b[K\u001b[21;39H\u001b[K\u001b[23;82H\u001b[K\u001b[21;3H"}
|
||||||
|
{"delayMs":34,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"}
|
||||||
|
{"delayMs":0,"data":"\u001b[18;34H\u001b[K\u001b[21;39H\u001b[K\u001b[23;82H\u001b[K\u001b[21;3H"}
|
||||||
|
{"delayMs":32,"data":"\u001b[18;34H\u001b[K\u001b[21;39H\u001b[K\u001b[23;82H\u001b[K\u001b[18;1H•\u001b[21;3H"}
|
||||||
|
{"delayMs":33,"data":"\u001b[18;34H\u001b[K\u001b[21;39H\u001b[K\u001b[23;82H\u001b[K\u001b[21;3H"}
|
||||||
|
{"delayMs":34,"data":"\u001b[?25l\u001b[?12l\u001b[?25h\u001b[18;34H\u001b[K\u001b[21;39H\u001b[K\u001b[23;82H\u001b[K\u001b[21;3H"}
|
||||||
|
{"delayMs":34,"data":"\u001b[3AW\u001b[30C\u001b[K\u001b[21;39H\u001b[K\u001b[23;82H\u001b[K\u001b[21;3H"}
|
||||||
|
{"delayMs":33,"data":"\u001b[18;34H\u001b[K\u001b[21;39H\u001b[K\u001b[23;82H\u001b[K\u001b[21;3H"}
|
||||||
|
{"delayMs":33,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"}
|
||||||
|
{"delayMs":0,"data":"\u001b[18;34H\u001b[K\u001b[21;39H\u001b[K\u001b[23;82H\u001b[K\u001b[21;3H"}
|
||||||
|
{"delayMs":33,"data":"\u001b[3A\u001b[1mW\u001b(B\u001b[mo\u001b[29C\u001b[K\u001b[21;39H\u001b[K\u001b[23;82H\u001b[K\u001b[21;3H"}
|
||||||
|
{"delayMs":33,"data":"\u001b[18;34H\u001b[K\u001b[21;39H\u001b[K\u001b[23;82H\u001b[K\u001b[21;3H"}
|
||||||
|
{"delayMs":33,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"}
|
||||||
|
{"delayMs":0,"data":"\u001b[18;4H\u001b[1mo\u001b(B\u001b[mr\u001b[28C\u001b[K\u001b[21;39H\u001b[K\u001b[23;82H\u001b[K\u001b[21;3H"}
|
||||||
|
{"delayMs":33,"data":"\u001b[18;34H\u001b[K\u001b[21;39H\u001b[K\u001b[23;82H\u001b[K\u001b[21;3H"}
|
||||||
|
{"delayMs":33,"data":"\u001b[18;5H\u001b[1mr\u001b(B\u001b[mk\u001b[27C\u001b[K\u001b[21;39H\u001b[K\u001b[23;82H\u001b[K\u001b[21;3H"}
|
||||||
|
{"delayMs":33,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"}
|
||||||
|
{"delayMs":0,"data":"\u001b[18;34H\u001b[K\u001b[21;39H\u001b[K\u001b[23;82H\u001b[K\u001b[21;3H"}
|
||||||
|
{"delayMs":33,"data":"\u001b[18;6H\u001b[1mk\u001b(B\u001b[mi\u001b[26C\u001b[K\u001b[21;39H\u001b[K\u001b[23;82H\u001b[K\u001b[21;3H"}
|
||||||
|
{"delayMs":32,"data":"\u001b[18;1H\u001b[J\u001b[A\u001b[K"}
|
||||||
|
{"delayMs":0,"data":"\u001b[17;30r\u001b[17;1H\u001bM\u001bM\u001b[1;30r\u001b[18;1H"}
|
||||||
|
{"delayMs":0,"data":"\u001b[2m• \u001b(B\u001b[mhello\u001b[21;1H\u001b[1m›\u001b[C\u001b(B\u001b[m\u001b[2mUse /skills to list available skills\u001b[23;3H\u001b(B\u001b[m\u001b[38;5;223mgpt-5.6-terra default\u001b[39m\u001b[2m · \u001b(B\u001b[m\u001b[38;5;151m~/default/claudeman-predictive/tmp/codexrec-work-SFpno1\u001b[21;3H\u001b(B\u001b[m"}
|
||||||
|
{"delayMs":25,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"}
|
||||||
|
{"delayMs":0,"data":"\u001b[36C\u001b[K\u001b[23;82H\u001b[K\u001b[21;3H"}
|
||||||
|
{"delayMs":6,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"}
|
||||||
|
{"delayMs":3,"data":"\u001b[36C\u001b[K\u001b[23;82H\u001b[K\u001b[21;3H"}
|
||||||
|
{"keyAt":true,"data":"a"}
|
||||||
|
{"delayMs":2252,"data":"a\u001b[K\u001b[23;82H\u001b[K\u001b[21;4H"}
|
||||||
|
{"keyAt":true,"data":"b"}
|
||||||
|
{"delayMs":121,"data":"b\u001b[K\u001b[23;82H\u001b[K\u001b[21;5H"}
|
||||||
|
{"keyAt":true,"data":"c"}
|
||||||
|
{"delayMs":121,"data":"c\u001b[K\u001b[23;82H\u001b[K\u001b[21;6H"}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
{"scenario":"trust-modal","cols":100,"rows":30,"codexVersion":"codex-cli 0.147.0","recordedAt":"2026-08-09T01:51:20.960Z"}
|
||||||
|
{"delayMs":0,"data":"\u001b[22;0;0t\u001b[?1h\u001b=\u001b[H\u001b[2J\u001b[?12l\u001b[?25h\u001b[?2004h\u001b(B\u001b[m\u001b[?12l\u001b[?25h\u001b[1;1H\u001b[1;30r\u001b[c\u001b[>c\u001b[>q\u001b]10;?\u001b\\\u001b]11;?\u001b\\\u001b[1;1H"}
|
||||||
|
{"delayMs":0,"data":"\u001b[?25l\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\u001b[?12l\u001b[?25h\u001b[H"}
|
||||||
|
{"delayMs":0,"data":"\u001b(B\u001b[m\u001b[?12l\u001b[?25h\u001b[1;1H\u001b[1;30r\u001b[1;1H"}
|
||||||
|
{"delayMs":0,"data":"\u001b[?25l\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\u001b[?12l\u001b[?25h\u001b[H"}
|
||||||
|
{"delayMs":28,"data":"\u001b[32m\u001b[1markon@tnode\u001b(B\u001b[m:\u001b[34m\u001b[1m~/default/claudeman-predictive/tmp/codexrec-work-X4gHpE\u001b(B\u001b[m$ "}
|
||||||
|
{"delayMs":654,"data":"exec codex\r\n"}
|
||||||
|
{"delayMs":486,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"}
|
||||||
|
{"delayMs":168,"data":"\u001b[30d\n\u001b[K\u001b[2d\u001b[J\u001b[H\u001b[K"}
|
||||||
|
{"delayMs":2,"data":">\u001b[C\u001b[1mYou are in \u001b(B\u001b[m/home/arkon/default/claudeman-predictive/tmp/codexrec-work-X4gHpE\u001b[3;3H\u001b[33mNote: You’re in a subdirectory of a Git project. Trusting will apply to the repository root:\u001b[4;3H/home/arkon/default/claudeman\u001b[6;3H\u001b[39mDo\u001b[Cyou\u001b[Ctrust\u001b[Cthe\u001b[Ccontents\u001b[Cof\u001b[Cthis\u001b[Cdirectory?\u001b[CWorking\u001b[Cwith\u001b[Cuntrusted\u001b[Ccontents\u001b[Ccomes\u001b[Cwith\u001b[Chigher\u001b[7;3Hrisk\u001b[Cof\u001b[Cprompt\u001b[Cinjection.\u001b[CTrusting\u001b[Cthe\u001b[Cdirectory\u001b[Callows\u001b[Cproject-local\u001b[Cconfig,\u001b[Chooks,\u001b[Cand\u001b[Cexec\u001b[8;3Hpolicies\u001b[Cto\u001b[Cload.\u001b[10;1H\u001b[36m› 1. Yes, continue\u001b[11;3H\u001b[39m2.\u001b[CNo,\u001b[Cquit\u001b[13;3H\u001b[2mPress enter to continue\u001b[?25l\u001b(B\u001b[m"}
|
||||||
|
{"delayMs":3659,"data":"\u001b[?7727h\u001b(B\u001b[m\u001b[?12l\u001b[?25h\u001b[1;1H\u001b[1;30r\u001b[13;26H\u001b[?25l"}
|
||||||
|
{"delayMs":0,"data":"\u001b[H>\u001b[1X\u001b[1m\u001b[CYou are in \u001b(B\u001b[m/home/arkon/default/claudeman-predictive/tmp/codexrec-work-X4gHpE\u001b[K\r\n\u001b[K\u001b[3;2H\u001b[1K\u001b[33m\u001b[CNote: You’re in a subdirectory of a Git project. Trusting will apply to the repository root:\u001b[39m\u001b[K\u001b[4;2H\u001b[1K\u001b[33m\u001b[C/home/arkon/default/claudeman\u001b[39m\u001b[K\r\n\u001b[K\u001b[6;2H\u001b[1K\u001b[CDo\u001b[1X\u001b[Cyou\u001b[1X\u001b[Ctrust\u001b[1X\u001b[Cthe\u001b[1X\u001b[Ccontents\u001b[1X\u001b[Cof\u001b[1X\u001b[Cthis\u001b[1X\u001b[Cdirectory?\u001b[1X\u001b[CWorking\u001b[1X\u001b[Cwith\u001b[1X\u001b[Cuntrusted\u001b[1X\u001b[Ccontents\u001b[1X\u001b[Ccomes\u001b[1X\u001b[Cwith\u001b[1X\u001b[Chigher\u001b[K\u001b[7;2H\u001b[1K\u001b[Crisk\u001b[1X\u001b[Cof\u001b[1X\u001b[Cprompt\u001b[1X\u001b[Cinjection.\u001b[1X\u001b[CTrusting\u001b[1X\u001b[Cthe\u001b[1X\u001b[Cdirectory\u001b[1X\u001b[Callows\u001b[1X\u001b[Cproject-local\u001b[1X\u001b[Cconfig,\u001b[1X\u001b[Chooks,\u001b[1X\u001b[Cand\u001b[1X\u001b[Cexec\u001b[K\u001b[8;2H\u001b[1K\u001b[Cpolicies\u001b[1X\u001b[Cto\u001b[1X\u001b[Cload.\u001b[K\r\n\u001b[K\u001b[36m\r\n› 1. Yes, continue\u001b[39m\u001b[K\u001b[11;2H\u001b[1K\u001b[C2.\u001b[1X\u001b[CNo,\u001b[1X\u001b[Cquit\u001b[K\r\n\u001b[K\u001b[13;2H\u001b[1K\u001b[2m\u001b[CPress enter to continue\u001b(B\u001b[m\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\u001b[13;26H"}
|
||||||
|
{"keyAt":true,"data":"x"}
|
||||||
|
{"delayMs":188,"data":"\u001b[1;79H\u001b[K\u001b[3;95H\u001b[K\u001b[4;32H\u001b[K\u001b[6;97H\u001b[K\u001b[7;96H\u001b[K\u001b[8;20H\u001b[K\u001b[10;19H\u001b[K\u001b[11;14H\u001b[K\u001b[13;26H\u001b[K\u001b[30;2H"}
|
||||||
|
{"keyAt":true,"data":"\r"}
|
||||||
|
{"delayMs":849,"data":"\u001b[2;1H\u001b[J\u001b[H\u001b[K"}
|
||||||
|
{"delayMs":0,"data":"\u001bM\u001bM\u001bM\r\n\u001b[33m⚠ Codex could not find bubblewrap on PATH. Install bubblewrap with your OS package manager. See the\r\n\u001b(B\u001b[m\u001b[1;3r\u001b[3;1H\n\u001b[A \u001b[33msandbox prerequisites: https://developers.openai.com/codex/concepts/sandboxing#prerequisites.\u001b[39m\r\n\u001b[K\u001b[1;30r\u001b[3;1H"}
|
||||||
|
{"delayMs":2,"data":" \u001b[33mCodex will use the bundled bubblewrap in the meantime.\u001b[5;1H\u001b[39m\u001b[2m╭─────────────────────────────────────────────────╮\r\n│ >_ \u001b(B\u001b[m\u001b[1mOpenAI Codex\u001b(B\u001b[m\u001b[2m (v0.147.0) │\r\n│ │\r\n│ model: \u001b[3mloading\u001b(B\u001b[m\u001b[2m \u001b(B\u001b[m\u001b[36m/model\u001b[39m\u001b[2m to change │\r\n│ directory: \u001b(B\u001b[m~/default/…/tmp/codexrec-work-X4gHpE\u001b[2m │\r\n╰─────────────────────────────────────────────────╯\u001b[13;1H\u001b(B\u001b[m\u001b[1m›\u001b[C\u001b(B\u001b[m\u001b[2mImprove docum\u001b(B\u001b[m"}
|
||||||
|
{"delayMs":0,"data":"\u001b[2mentation in @filename\u001b[15;3H\u001b(B\u001b[m\u001b[38;5;223mgpt-5.6-sol default\u001b[39m\u001b[2m · \u001b(B\u001b[m\u001b[38;5;151m~/default/claudeman-predictive/tmp/codexrec-work-X4gHpE\u001b[13;3H\u001b[?12l\u001b[?25h\u001b(B\u001b[m"}
|
||||||
|
{"delayMs":7,"data":"\u001b[5;52H\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\u001b[13;37H\u001b[K\u001b[15;80H\u001b[K\u001b[13;3H"}
|
||||||
|
{"delayMs":13,"data":"\u001b[5;52H\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\u001b[13;37H\u001b[K\u001b[15;80H\u001b[K\u001b[13;3H"}
|
||||||
|
{"delayMs":165,"data":"\u001b[5;1H\u001b[J\u001b[A\u001b[K"}
|
||||||
|
{"delayMs":0,"data":"\u001b[2B\u001b[1m›\u001b[C\u001b(B\u001b[m\u001b[2mImprove documentation in @filename\u001b[8;3H\u001b(B\u001b[m\u001b[38;5;223mgpt-5.6-sol default\u001b[39m\u001b[2m · \u001b(B\u001b[m\u001b[38;5;151m~/default/claudeman-predictive/tmp/codexrec-work-X4gHpE\u001b[6;3H\u001b(B\u001b[m"}
|
||||||
|
{"delayMs":1,"data":"\u001b[34C\u001b[K\u001b[8;80H\u001b[K\u001b[6;3H"}
|
||||||
|
{"delayMs":24,"data":"\u001b[4;30r\u001b[4;1H\u001bM\u001bM\u001bM\u001bM\u001bM\u001bM\u001bM\u001bM\u001bM\u001bM\u001bM\r\n\u001b[2m╭─────────────────────────────────────────────────╮\r\n│ >_ \u001b(B\u001b[m\u001b[1mOpenAI Codex\u001b(B\u001b[m\u001b[2m (v0.147.0) │\u001b[1;30r\u001b[7;1H\u001b(B\u001b[m"}
|
||||||
|
{"delayMs":0,"data":"\u001b[2m│ │\r\n│ model: \u001b(B\u001b[mgpt-5.6-sol\u001b[2m \u001b(B\u001b[m\u001b[36m/model\u001b[39m\u001b[2m to change │\r\n│ directory: \u001b(B\u001b[m~/default/…/tmp/codexrec-work-X4gHpE\u001b[2m │\r\n╰─────────────────────────────────────────────────╯\u001b[12;1H\u001b(B\u001b[m \u001b[1mTip:\u001b(B\u001b[m Our most capable model yet. GPT-5.6 Sol can tackle complex code changes, dig into research,\r\n produce polished documents, and take on your most ambitious work. Sol is highly capable at lower\r\n"}
|
||||||
|
{"delayMs":0,"data":" reasoning efforts—try starting lower, then turn it up for harder jobs.\u001b[17;37H\u001b[K\u001b[19;80H\u001b[K\u001b[17;3H"}
|
||||||
|
{"delayMs":1,"data":"\u001b[34C\u001b[K\u001b[19;80H\u001b[K\u001b[17;3H"}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
{"scenario":"type-hello","cols":100,"rows":30,"codexVersion":"codex-cli 0.147.0","recordedAt":"2026-08-09T01:50:33.854Z"}
|
||||||
|
{"delayMs":0,"data":"\u001b[22;0;0t\u001b[?1h\u001b=\u001b[H\u001b[2J\u001b[?12l\u001b[?25h\u001b[?2004h\u001b(B\u001b[m\u001b[?12l\u001b[?25h\u001b[1;1H\u001b[1;30r\u001b[c\u001b[>c\u001b[>q\u001b]10;?\u001b\\\u001b]11;?\u001b\\\u001b[1;1H"}
|
||||||
|
{"delayMs":1,"data":"\u001b[?25l\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\u001b[?12l\u001b[?25h\u001b[H\u001b(B\u001b[m\u001b[?12l\u001b[?25h\u001b[1;1H\u001b[1;30r\u001b[1;1H\u001b[?25l\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\u001b[?12l\u001b[?25h\u001b[H"}
|
||||||
|
{"delayMs":32,"data":"\u001b[32m\u001b[1markon@tnode\u001b(B\u001b[m:\u001b[34m\u001b[1m~/default/claudeman-predictive/tmp/codexrec-work-tXbGez\u001b(B\u001b[m$ "}
|
||||||
|
{"delayMs":651,"data":"exec codex\r\n"}
|
||||||
|
{"delayMs":403,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"}
|
||||||
|
{"delayMs":189,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"}
|
||||||
|
{"delayMs":7,"data":"\r\n\u001b[J\u001b[A\u001b[K"}
|
||||||
|
{"delayMs":4,"data":"\u001b[2;30r\u001b[2;1H\u001bM\u001bM\u001bM\u001b[1;30r\u001b[2;1H\u001b[33m⚠ Codex could not find bubblewrap on PATH. Install bubblewrap with your OS package manager. See the\r\n\u001b[39m \u001b[33msandbox prerequisites: https://developers.openai.com/codex/concepts/sandboxing#prerequisites.\r\n\u001b[39m \u001b[33mCodex will use the bundled bubblewrap in the meantime.\u001b[6;1H\u001b[39m\u001b[2m╭─────────────────────────────────────────────────╮\r\n│ >_ \u001b(B\u001b[m\u001b[1mOpenAI Codex\u001b(B\u001b[m\u001b[2m (v0.147.0) │\r\n│ │\r\n│ model: \u001b[3mloading\u001b(B\u001b[m\u001b[2m \u001b(B\u001b[m\u001b[36m/model\u001b[39m\u001b[2m to change │\r\n│ directory: \u001b(B\u001b[m~/default/…/tmp/codexrec-work-tXbGez\u001b[2m │\r\n╰─────────────────────────────────────────────────╯\u001b[14;1H\u001b(B\u001b[m\u001b[1m›\u001b[C\u001b(B\u001b[m\u001b[2mImprove documentation in @filename\u001b[16;3H\u001b(B\u001b[m\u001b[38;5;223mgpt-5.6-sol default\u001b[39m\u001b[2m · \u001b(B\u001b[m\u001b[38;5;151m~/default/claudeman-predictive/tmp/codexrec-work-tXbGez\u001b[14;3H\u001b(B\u001b[m"}
|
||||||
|
{"delayMs":6,"data":"\u001b[6;52H\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\u001b[14;37H\u001b[K\u001b[16;80H\u001b[K\u001b[14;3H"}
|
||||||
|
{"delayMs":25,"data":"\u001b[6;52H\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\u001b[14;37H\u001b[K\u001b[16;80H\u001b[K\u001b[14;3H"}
|
||||||
|
{"delayMs":8,"data":"\u001b[6;52H\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\u001b[14;37H\u001b[K\u001b[16;80H\u001b[K\u001b[14;3H"}
|
||||||
|
{"delayMs":185,"data":"\u001b[6;1H\u001b[J\u001b[A\u001b[K"}
|
||||||
|
{"delayMs":0,"data":"\u001b[5;30r\u001b[5;1H\u001bM\u001bM\u001bM\u001bM\u001bM\u001bM\u001bM\u001bM\u001bM\u001bM\u001bM\u001b[1;30r\u001b[5;1H"}
|
||||||
|
{"delayMs":0,"data":"\r\n\u001b[2m╭─────────────────────────────────────────────────╮\r\n\u001b(B\u001b[m"}
|
||||||
|
{"delayMs":0,"data":"\u001b[2m│ >_ \u001b(B\u001b[m\u001b[1mOpenAI Codex\u001b(B\u001b[m\u001b[2m (v0.147.0) │\r\n│ │\r\n│ model: \u001b(B\u001b[mgpt-5.6-sol\u001b[2m \u001b(B\u001b[m\u001b[36m/model\u001b[39m\u001b[2m to change │\r\n\u001b(B\u001b[m"}
|
||||||
|
{"delayMs":0,"data":"\u001b[2m│ directory: \u001b(B\u001b[m~/default/…/tmp/codexrec-work-tXbGez\u001b[2m │\r\n╰─────────────────────────────────────────────────╯\u001b[13;1H\u001b(B\u001b[m \u001b[1mTip:\u001b(B\u001b[m Our most capable model yet. GPT-5.6 Sol can tackle complex code changes, dig into research,\r\n"}
|
||||||
|
{"delayMs":0,"data":" produce polished documents, and take on your most ambitious work. Sol is highly capable at lower\r\n"}
|
||||||
|
{"delayMs":0,"data":" reasoning efforts—try starting lower, then turn it up for harder jobs.\u001b[18;1H\u001b[1m›\u001b[C\u001b(B\u001b[m\u001b[2mImprove documentation in @filename\u001b[20;3H\u001b(B\u001b[m\u001b[38;5;223mgpt-5.6-sol default\u001b[39m\u001b[2m · \u001b(B\u001b[m\u001b[38;5;151m~/default/claudeman-predictive/tmp/codexrec-work-tXbGez\u001b[18;3H\u001b(B\u001b[m"}
|
||||||
|
{"delayMs":0,"data":"\u001b[34C\u001b[K\u001b[20;80H\u001b[K\u001b[18;3H"}
|
||||||
|
{"delayMs":0,"data":"\u001b[34C\u001b[K\u001b[20;80H\u001b[K\u001b[18;3H"}
|
||||||
|
{"delayMs":3484,"data":"\u001b[?7727h\u001b(B\u001b[m\u001b[?12l\u001b[?25h\u001b[1;1H\u001b[1;30r\u001b[18;3H"}
|
||||||
|
{"delayMs":0,"data":"\u001b[?25l\u001b[32m\u001b[1m\u001b[Harkon@tnode\u001b(B\u001b[m:\u001b[34m\u001b[1m~/default/claudeman-predictive/tmp/codexrec-work-tXbGez\u001b(B\u001b[m$ exec codex\u001b[K\u001b[33m\r\n⚠ Codex could not find bubblewrap on PATH. Install bubblewrap with your OS package manager. See the\u001b[39m\u001b[K\r\n \u001b[33msandbox prerequisites: https://developers.openai.com/codex/concepts/sandboxing#prerequisites.\u001b[39m\u001b[K\r\n \u001b[33mCodex will use the bundled bubblewrap in the meantime.\u001b[39m\u001b[K\r\n\u001b[K\u001b[2m\r\n╭─────────────────────────────────────────────────╮\u001b(B\u001b[m\u001b[K\u001b[2m\r\n│ >_ \u001b(B\u001b[m\u001b[1mOpenAI Codex\u001b(B\u001b[m\u001b[2m (v0.147.0) │\u001b(B\u001b[m\u001b[K\u001b[2m\r\n│ │\u001b(B\u001b[m\u001b[K\u001b[2m\r\n│ model: \u001b(B\u001b[mgpt-5.6-sol\u001b[2m \u001b(B\u001b[m\u001b[36m/model\u001b[39m\u001b[2m to change │\u001b(B\u001b[m\u001b[K\u001b[2m\r\n│ directory: \u001b(B\u001b[m~/default/…/tmp/codexrec-work-tXbGez\u001b[2m │\u001b(B\u001b[m\u001b[K\u001b[2m\r\n╰─────────────────────────────────────────────────╯\u001b(B\u001b[m\u001b[K\r\n\u001b[K\r\n \u001b[1mTip:\u001b(B\u001b[m Our most capable model yet. GPT-5.6 Sol can tackle complex code changes, dig into research,\u001b[K\r\n produce polished documents, and take on your most ambitious work. Sol is highly capable at lower\u001b[K\r\n reasoning efforts—try starting lower, then turn it up for harder jobs.\u001b[K\r\n\u001b[K\r\n\u001b[K\u001b[1m\r\n›\u001b(B\u001b[m\u001b[1X\u001b[2m\u001b[CImprove documentation in @filename\u001b(B\u001b[m\u001b[K\r\n\u001b[K\u001b[20;2H\u001b[1K\u001b[38;5;223m\u001b[Cgpt-5.6-sol default\u001b[39m\u001b[2m · \u001b(B\u001b[m\u001b[38;5;151m~/default/claudeman-predictive/tmp/codexrec-work-tXbGez\u001b[39m\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\u001b[?12l\u001b[?25h\u001b[18;3H"}
|
||||||
|
{"keyAt":true,"data":"h"}
|
||||||
|
{"delayMs":208,"data":"h\u001b[K\u001b[20;80H\u001b[K\u001b[18;4H"}
|
||||||
|
{"keyAt":true,"data":"e"}
|
||||||
|
{"delayMs":93,"data":"e\u001b[K\u001b[20;80H\u001b[K\u001b[18;5H"}
|
||||||
|
{"keyAt":true,"data":"l"}
|
||||||
|
{"delayMs":89,"data":"l\u001b[K\u001b[20;80H\u001b[K\u001b[18;6H"}
|
||||||
|
{"keyAt":true,"data":"l"}
|
||||||
|
{"delayMs":92,"data":"l\u001b[K\u001b[20;80H\u001b[K\u001b[18;7H"}
|
||||||
|
{"keyAt":true,"data":"o"}
|
||||||
|
{"delayMs":90,"data":"o\u001b[K\u001b[20;80H\u001b[K\u001b[18;8H"}
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
{"scenario":"wrap","cols":100,"rows":30,"codexVersion":"codex-cli 0.147.0","recordedAt":"2026-08-09T01:50:52.462Z"}
|
||||||
|
{"delayMs":0,"data":"\u001b[22;0;0t\u001b[?1h\u001b=\u001b[H\u001b[2J\u001b[?12l\u001b[?25h\u001b[?2004h\u001b(B\u001b[m\u001b[?12l\u001b[?25h\u001b[1;1H\u001b[1;30r\u001b[c\u001b[>c\u001b[>q\u001b]10;?\u001b\\\u001b]11;?\u001b\\\u001b[1;1H"}
|
||||||
|
{"delayMs":0,"data":"\u001b[?25l\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\u001b[?12l\u001b[?25h\u001b[H"}
|
||||||
|
{"delayMs":0,"data":"\u001b(B\u001b[m\u001b[?12l\u001b[?25h\u001b[1;1H\u001b[1;30r\u001b[1;1H"}
|
||||||
|
{"delayMs":0,"data":"\u001b[?25l\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\u001b[?12l\u001b[?25h\u001b[H"}
|
||||||
|
{"delayMs":32,"data":"\u001b[32m\u001b[1markon@tnode\u001b(B\u001b[m:\u001b[34m\u001b[1m~/default/claudeman-predictive/tmp/codexrec-work-VGU83J\u001b(B\u001b[m$ "}
|
||||||
|
{"delayMs":650,"data":"exec codex\r\n"}
|
||||||
|
{"delayMs":437,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"}
|
||||||
|
{"delayMs":181,"data":"\u001b[?25l\u001b[?12l\u001b[?25h"}
|
||||||
|
{"delayMs":4,"data":"\r\n\u001b[J\u001b[A\u001b[K"}
|
||||||
|
{"delayMs":0,"data":"\u001b[2;30r\u001b[2;1H\u001bM\u001bM\u001bM\u001b[1;30r\u001b[2;1H"}
|
||||||
|
{"delayMs":0,"data":"\u001b[33m⚠ Codex could not find bubblewrap on PATH. Install bubblewrap with your OS package manager. See the\r\n\u001b[39m \u001b[33msandbox prerequisites: https://developers.openai.com/codex/concepts/sandboxing#prerequisites.\r\n\u001b(B\u001b[m"}
|
||||||
|
{"delayMs":1,"data":" \u001b[33mCodex will use the bundled bubblewrap in the meantime.\u001b[6;1H\u001b[39m\u001b[2m╭─────────────────────────────────────────────────╮\r\n│ >_ \u001b(B\u001b[m\u001b[1mOpenAI Codex\u001b(B\u001b[m\u001b[2m (v0.147.0) │\r\n│ │\r\n│ model: \u001b[3mloading\u001b(B\u001b[m\u001b[2m \u001b(B\u001b[m\u001b[36m/model\u001b[39m\u001b[2m to change │\r\n│ directory: \u001b(B\u001b[m~/default/…/tmp/codexrec-work-VGU83J\u001b[2m │\r\n╰─────────────────────────────────────────────────╯\u001b[14;1H\u001b(B\u001b[m\u001b[1m›\u001b[C\u001b(B\u001b[m\u001b[2mWrite tests for @filename\u001b[16;3H\u001b(B\u001b[m\u001b[38;5;223mgpt-5.6-sol default\u001b[39m\u001b[2m · \u001b(B\u001b[m\u001b[38;5;151m~/default/claudeman-predictive/tmp/codexrec-work-VGU83J\u001b[14;3H\u001b(B\u001b[m"}
|
||||||
|
{"delayMs":6,"data":"\u001b[6;52H\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\u001b[14;28H\u001b[K\u001b[16;80H\u001b[K\u001b[14;3H"}
|
||||||
|
{"delayMs":19,"data":"\u001b[6;52H\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\u001b[14;28H\u001b[K\u001b[16;80H\u001b[K\u001b[14;3H"}
|
||||||
|
{"delayMs":6,"data":"\u001b[6;52H\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\u001b[14;28H\u001b[K\u001b[16;80H\u001b[K\u001b[14;3H"}
|
||||||
|
{"delayMs":160,"data":"\u001b[6;1H\u001b[J\u001b[A\u001b[K"}
|
||||||
|
{"delayMs":0,"data":"\u001b[5;30r\u001b[5;1H\u001bM\u001bM\u001bM\u001bM\u001bM\u001bM\u001bM\u001bM\u001bM\u001bM\u001bM\u001b[1;30r\u001b[5;1H"}
|
||||||
|
{"delayMs":0,"data":"\r\n\u001b[2m╭─────────────────────────────────────────────────╮\r\n│ >_ \u001b(B\u001b[m\u001b[1mOpenAI Codex\u001b(B\u001b[m\u001b[2m (v0.147.0) │\r\n│ │\r\n\u001b(B\u001b[m"}
|
||||||
|
{"delayMs":0,"data":"\u001b[2m│ model: \u001b(B\u001b[mgpt-5.6-sol\u001b[2m \u001b(B\u001b[m\u001b[36m/model\u001b[39m\u001b[2m to change │\r\n│ directory: \u001b(B\u001b[m~/default/…/tmp/codexrec-work-VGU83J\u001b[2m │\r\n╰─────────────────────────────────────────────────╯\u001b[13;1H\u001b(B\u001b[m \u001b[1mTip:\u001b(B\u001b[m Our most capable model yet. GPT-5.6 Sol can tackle complex code changes, dig into research,\r\n produce polished documents, and take on your most ambitious work. Sol is highly capable at lower\r\n"}
|
||||||
|
{"delayMs":0,"data":" reasoning efforts—try starting lower, then turn it up for harder jobs.\u001b[18;1H\u001b[1m›\u001b[C\u001b(B\u001b[m\u001b[2mWrite tests for @filename\u001b[20;3H\u001b(B\u001b[m\u001b[38;5;223mgpt-5.6-sol default\u001b[39m\u001b[2m · \u001b(B\u001b[m\u001b[38;5;151m~/default/claudeman-predictive/tmp/codexrec-work-VGU83J\u001b[18;3H\u001b(B\u001b[m"}
|
||||||
|
{"delayMs":18,"data":"\u001b[25C\u001b[K\u001b[20;80H\u001b[K\u001b[18;3H"}
|
||||||
|
{"delayMs":1,"data":"\u001b[25C\u001b[K\u001b[20;80H\u001b[K\u001b[18;3H"}
|
||||||
|
{"delayMs":3478,"data":"\u001b[?7727h\u001b(B\u001b[m\u001b[?12l\u001b[?25h\u001b[1;1H\u001b[1;30r\u001b[18;3H"}
|
||||||
|
{"delayMs":0,"data":"\u001b[?25l\u001b[32m\u001b[1m\u001b[Harkon@tnode\u001b(B\u001b[m:\u001b[34m\u001b[1m~/default/claudeman-predictive/tmp/codexrec-work-VGU83J\u001b(B\u001b[m$ exec codex\u001b[K\u001b[33m\r\n⚠ Codex could not find bubblewrap on PATH. Install bubblewrap with your OS package manager. See the\u001b[39m\u001b[K\r\n \u001b[33msandbox prerequisites: https://developers.openai.com/codex/concepts/sandboxing#prerequisites.\u001b[39m\u001b[K\r\n \u001b[33mCodex will use the bundled bubblewrap in the meantime.\u001b[39m\u001b[K\r\n\u001b[K\u001b[2m\r\n╭─────────────────────────────────────────────────╮\u001b(B\u001b[m\u001b[K\u001b[2m\r\n│ >_ \u001b(B\u001b[m\u001b[1mOpenAI Codex\u001b(B\u001b[m\u001b[2m (v0.147.0) │\u001b(B\u001b[m\u001b[K\u001b[2m\r\n│ │\u001b(B\u001b[m\u001b[K\u001b[2m\r\n│ model: \u001b(B\u001b[mgpt-5.6-sol\u001b[2m \u001b(B\u001b[m\u001b[36m/model\u001b[39m\u001b[2m to change │\u001b(B\u001b[m\u001b[K\u001b[2m\r\n│ directory: \u001b(B\u001b[m~/default/…/tmp/codexrec-work-VGU83J\u001b[2m │\u001b(B\u001b[m\u001b[K\u001b[2m\r\n╰─────────────────────────────────────────────────╯\u001b(B\u001b[m\u001b[K\r\n\u001b[K\r\n \u001b[1mTip:\u001b(B\u001b[m Our most capable model yet. GPT-5.6 Sol can tackle complex code changes, dig into research,\u001b[K\r\n produce polished documents, and take on your most ambitious work. Sol is highly capable at lower\u001b[K\r\n reasoning efforts—try starting lower, then turn it up for harder jobs.\u001b[K\r\n\u001b[K\r\n\u001b[K\u001b[1m\r\n›\u001b(B\u001b[m\u001b[1X\u001b[2m\u001b[CWrite tests for @filename\u001b(B\u001b[m\u001b[K\r\n\u001b[K\u001b[20;2H\u001b[1K\u001b[38;5;223m\u001b[Cgpt-5.6-sol default\u001b[39m\u001b[2m · \u001b(B\u001b[m\u001b[38;5;151m~/default/claudeman-predictive/tmp/codexrec-work-VGU83J\u001b[39m\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\u001b[?12l\u001b[?25h\u001b[18;3H"}
|
||||||
|
{"keyAt":true,"data":"t"}
|
||||||
|
{"delayMs":232,"data":"t\u001b[K\u001b[20;80H\u001b[K\u001b[18;4H"}
|
||||||
|
{"keyAt":true,"data":"h"}
|
||||||
|
{"delayMs":27,"data":"h\u001b[K\u001b[20;80H\u001b[K\u001b[18;5H"}
|
||||||
|
{"keyAt":true,"data":"e"}
|
||||||
|
{"delayMs":27,"data":"e\u001b[K\u001b[20;80H\u001b[K\u001b[18;6H"}
|
||||||
|
{"keyAt":true,"data":" "}
|
||||||
|
{"delayMs":27,"data":"\u001b[K\u001b[20;80H\u001b[K\u001b[18;7H"}
|
||||||
|
{"keyAt":true,"data":"q"}
|
||||||
|
{"delayMs":26,"data":"q\u001b[K\u001b[20;80H\u001b[K\u001b[18;8H"}
|
||||||
|
{"keyAt":true,"data":"u"}
|
||||||
|
{"delayMs":17,"data":"u\u001b[K\u001b[20;80H\u001b[K\u001b[18;9H"}
|
||||||
|
{"keyAt":true,"data":"i"}
|
||||||
|
{"delayMs":28,"data":"i\u001b[K\u001b[20;80H\u001b[K\u001b[18;10H"}
|
||||||
|
{"keyAt":true,"data":"c"}
|
||||||
|
{"delayMs":27,"data":"c\u001b[K\u001b[20;80H\u001b[K\u001b[18;11H"}
|
||||||
|
{"keyAt":true,"data":"k"}
|
||||||
|
{"delayMs":27,"data":"k\u001b[K\u001b[20;80H\u001b[K\u001b[18;12H"}
|
||||||
|
{"keyAt":true,"data":" "}
|
||||||
|
{"keyAt":true,"data":"b"}
|
||||||
|
{"delayMs":27,"data":"\u001b[K\u001b[20;80H\u001b[K\u001b[18;13H"}
|
||||||
|
{"delayMs":16,"data":"b\u001b[K\u001b[20;80H\u001b[K\u001b[18;14H"}
|
||||||
|
{"keyAt":true,"data":"r"}
|
||||||
|
{"delayMs":30,"data":"r\u001b[K\u001b[20;80H\u001b[K\u001b[18;15H"}
|
||||||
|
{"keyAt":true,"data":"o"}
|
||||||
|
{"delayMs":27,"data":"o\u001b[K\u001b[20;80H\u001b[K\u001b[18;16H"}
|
||||||
|
{"keyAt":true,"data":"w"}
|
||||||
|
{"delayMs":27,"data":"w\u001b[K\u001b[20;80H\u001b[K\u001b[18;17H"}
|
||||||
|
{"keyAt":true,"data":"n"}
|
||||||
|
{"delayMs":27,"data":"n\u001b[K\u001b[20;80H\u001b[K\u001b[18;18H"}
|
||||||
|
{"keyAt":true,"data":" "}
|
||||||
|
{"keyAt":true,"data":"f"}
|
||||||
|
{"delayMs":45,"data":"\u001b[Cf\u001b[K\u001b[20;80H\u001b[K\u001b[18;20H"}
|
||||||
|
{"keyAt":true,"data":"o"}
|
||||||
|
{"delayMs":27,"data":"o\u001b[K\u001b[20;80H\u001b[K\u001b[18;21H"}
|
||||||
|
{"keyAt":true,"data":"x"}
|
||||||
|
{"delayMs":26,"data":"x\u001b[K\u001b[20;80H\u001b[K\u001b[18;22H"}
|
||||||
|
{"keyAt":true,"data":" "}
|
||||||
|
{"delayMs":27,"data":"\u001b[K\u001b[20;80H\u001b[K\u001b[18;23H"}
|
||||||
|
{"keyAt":true,"data":"j"}
|
||||||
|
{"keyAt":true,"data":"u"}
|
||||||
|
{"delayMs":27,"data":"j\u001b[K\u001b[20;80H\u001b[K\u001b[18;24H"}
|
||||||
|
{"keyAt":true,"data":"m"}
|
||||||
|
{"delayMs":46,"data":"um\u001b[K\u001b[20;80H\u001b[K\u001b[18;26H"}
|
||||||
|
{"keyAt":true,"data":"p"}
|
||||||
|
{"delayMs":26,"data":"p\u001b[K\u001b[20;80H\u001b[K\u001b[18;27H"}
|
||||||
|
{"keyAt":true,"data":"s"}
|
||||||
|
{"delayMs":28,"data":"s\u001b[K\u001b[20;80H\u001b[K\u001b[18;28H"}
|
||||||
|
{"keyAt":true,"data":" "}
|
||||||
|
{"delayMs":27,"data":"\u001b[K\u001b[20;80H\u001b[K\u001b[18;29H"}
|
||||||
|
{"keyAt":true,"data":"o"}
|
||||||
|
{"delayMs":16,"data":"o\u001b[K\u001b[20;80H\u001b[K\u001b[18;30H"}
|
||||||
|
{"keyAt":true,"data":"v"}
|
||||||
|
{"delayMs":31,"data":"v\u001b[K\u001b[20;80H\u001b[K\u001b[18;31H"}
|
||||||
|
{"keyAt":true,"data":"e"}
|
||||||
|
{"delayMs":26,"data":"e\u001b[K\u001b[20;80H\u001b[K\u001b[18;32H"}
|
||||||
|
{"keyAt":true,"data":"r"}
|
||||||
|
{"delayMs":28,"data":"r\u001b[K\u001b[20;80H\u001b[K\u001b[18;33H"}
|
||||||
|
{"keyAt":true,"data":" "}
|
||||||
|
{"delayMs":26,"data":"\u001b[K\u001b[20;80H\u001b[K\u001b[18;34H"}
|
||||||
|
{"keyAt":true,"data":"t"}
|
||||||
|
{"keyAt":true,"data":"h"}
|
||||||
|
{"delayMs":45,"data":"th\u001b[K\u001b[20;80H\u001b[K\u001b[18;36H"}
|
||||||
|
{"keyAt":true,"data":"e"}
|
||||||
|
{"delayMs":27,"data":"e\u001b[K\u001b[20;80H\u001b[K\u001b[18;37H"}
|
||||||
|
{"keyAt":true,"data":" "}
|
||||||
|
{"delayMs":27,"data":"\u001b[K\u001b[20;80H\u001b[K\u001b[18;38H"}
|
||||||
|
{"keyAt":true,"data":"l"}
|
||||||
|
{"delayMs":27,"data":"l\u001b[K\u001b[20;80H\u001b[K\u001b[18;39H"}
|
||||||
|
{"keyAt":true,"data":"a"}
|
||||||
|
{"keyAt":true,"data":"z"}
|
||||||
|
{"delayMs":28,"data":"a\u001b[K\u001b[20;80H\u001b[K\u001b[18;40H"}
|
||||||
|
{"keyAt":true,"data":"y"}
|
||||||
|
{"delayMs":26,"data":"z\u001b[K\u001b[20;80H\u001b[K\u001b[18;41H"}
|
||||||
|
{"delayMs":17,"data":"y\u001b[K\u001b[20;80H\u001b[K\u001b[18;42H"}
|
||||||
|
{"keyAt":true,"data":" "}
|
||||||
|
{"delayMs":29,"data":"\u001b[K\u001b[20;80H\u001b[K\u001b[18;43H"}
|
||||||
|
{"keyAt":true,"data":"d"}
|
||||||
|
{"delayMs":27,"data":"d\u001b[K\u001b[20;80H\u001b[K\u001b[18;44H"}
|
||||||
|
{"keyAt":true,"data":"o"}
|
||||||
|
{"delayMs":27,"data":"o\u001b[K\u001b[20;80H\u001b[K\u001b[18;45H"}
|
||||||
|
{"keyAt":true,"data":"g"}
|
||||||
|
{"delayMs":27,"data":"g\u001b[K\u001b[20;80H\u001b[K\u001b[18;46H"}
|
||||||
|
{"keyAt":true,"data":" "}
|
||||||
|
{"keyAt":true,"data":"a"}
|
||||||
|
{"delayMs":45,"data":"\u001b[Ca\u001b[K\u001b[20;80H\u001b[K\u001b[18;48H"}
|
||||||
|
{"keyAt":true,"data":"n"}
|
||||||
|
{"delayMs":26,"data":"n\u001b[K\u001b[20;80H\u001b[K\u001b[18;49H"}
|
||||||
|
{"keyAt":true,"data":"d"}
|
||||||
|
{"delayMs":28,"data":"d\u001b[K\u001b[20;80H\u001b[K\u001b[18;50H"}
|
||||||
|
{"keyAt":true,"data":" "}
|
||||||
|
{"delayMs":26,"data":"\u001b[K\u001b[20;80H\u001b[K\u001b[18;51H"}
|
||||||
|
{"keyAt":true,"data":"k"}
|
||||||
|
{"keyAt":true,"data":"e"}
|
||||||
|
{"delayMs":27,"data":"k\u001b[20;80H\u001b[K\u001b[18;52H"}
|
||||||
|
{"keyAt":true,"data":"e"}
|
||||||
|
{"delayMs":26,"data":"e\u001b[K\u001b[20;80H\u001b[K\u001b[18;53H"}
|
||||||
|
{"delayMs":17,"data":"e\u001b[K\u001b[20;80H\u001b[K\u001b[18;54H"}
|
||||||
|
{"keyAt":true,"data":"p"}
|
||||||
|
{"delayMs":28,"data":"p\u001b[K\u001b[20;80H\u001b[K\u001b[18;55H"}
|
||||||
|
{"keyAt":true,"data":"s"}
|
||||||
|
{"delayMs":27,"data":"s\u001b[K\u001b[20;80H\u001b[K\u001b[18;56H"}
|
||||||
|
{"keyAt":true,"data":" "}
|
||||||
|
{"delayMs":27,"data":"\u001b[K\u001b[20;80H\u001b[K\u001b[18;57H"}
|
||||||
|
{"keyAt":true,"data":"r"}
|
||||||
|
{"keyAt":true,"data":"u"}
|
||||||
|
{"delayMs":27,"data":"r\u001b[K\u001b[20;80H\u001b[K\u001b[18;58H"}
|
||||||
|
{"delayMs":17,"data":"u\u001b[K\u001b[20;80H\u001b[K\u001b[18;59H"}
|
||||||
|
{"keyAt":true,"data":"n"}
|
||||||
|
{"delayMs":29,"data":"n\u001b[K\u001b[20;80H\u001b[K\u001b[18;60H"}
|
||||||
|
{"keyAt":true,"data":"n"}
|
||||||
|
{"delayMs":26,"data":"n\u001b[K\u001b[20;80H\u001b[K\u001b[18;61H"}
|
||||||
|
{"keyAt":true,"data":"i"}
|
||||||
|
{"delayMs":28,"data":"i\u001b[K\u001b[20;80H\u001b[K\u001b[18;62H"}
|
||||||
|
{"keyAt":true,"data":"n"}
|
||||||
|
{"delayMs":26,"data":"n\u001b[K\u001b[20;80H\u001b[K\u001b[18;63H"}
|
||||||
|
{"keyAt":true,"data":"g"}
|
||||||
|
{"keyAt":true,"data":" "}
|
||||||
|
{"delayMs":45,"data":"g\u001b[K\u001b[20;80H\u001b[K\u001b[18;65H"}
|
||||||
|
{"keyAt":true,"data":"u"}
|
||||||
|
{"delayMs":28,"data":"u\u001b[K\u001b[20;80H\u001b[K\u001b[18;66H"}
|
||||||
|
{"keyAt":true,"data":"n"}
|
||||||
|
{"delayMs":27,"data":"n\u001b[K\u001b[20;80H\u001b[K\u001b[18;67H"}
|
||||||
|
{"keyAt":true,"data":"t"}
|
||||||
|
{"keyAt":true,"data":"i"}
|
||||||
|
{"delayMs":27,"data":"t\u001b[K\u001b[20;80H\u001b[K\u001b[18;68H"}
|
||||||
|
{"keyAt":true,"data":"l"}
|
||||||
|
{"delayMs":45,"data":"il\u001b[K\u001b[20;80H\u001b[K\u001b[18;70H"}
|
||||||
|
{"keyAt":true,"data":" "}
|
||||||
|
{"delayMs":28,"data":"\u001b[K\u001b[20;80H\u001b[K\u001b[18;71H"}
|
||||||
|
{"keyAt":true,"data":"t"}
|
||||||
|
{"delayMs":26,"data":"t\u001b[K\u001b[20;80H\u001b[K\u001b[18;72H"}
|
||||||
|
{"keyAt":true,"data":"h"}
|
||||||
|
{"keyAt":true,"data":"e"}
|
||||||
|
{"delayMs":28,"data":"h\u001b[K\u001b[20;80H\u001b[K\u001b[18;73H"}
|
||||||
|
{"keyAt":true,"data":" "}
|
||||||
|
{"delayMs":45,"data":"e\u001b[K\u001b[20;80H\u001b[K\u001b[18;75H"}
|
||||||
|
{"keyAt":true,"data":"c"}
|
||||||
|
{"delayMs":27,"data":"c\u001b[K\u001b[20;80H\u001b[K\u001b[18;76H"}
|
||||||
|
{"keyAt":true,"data":"o"}
|
||||||
|
{"delayMs":28,"data":"o\u001b[K\u001b[20;80H\u001b[K\u001b[18;77H"}
|
||||||
|
{"keyAt":true,"data":"m"}
|
||||||
|
{"keyAt":true,"data":"p"}
|
||||||
|
{"delayMs":27,"data":"m\u001b[K\u001b[20;80H\u001b[K\u001b[18;78H"}
|
||||||
|
{"delayMs":16,"data":"p\u001b[K\u001b[20;80H\u001b[K\u001b[18;79H"}
|
||||||
|
{"keyAt":true,"data":"o"}
|
||||||
|
{"delayMs":30,"data":"o\u001b[K\u001b[2B\u001b[K\u001b[2A"}
|
||||||
|
{"keyAt":true,"data":"s"}
|
||||||
|
{"delayMs":27,"data":"s\u001b[K\u001b[20;80H\u001b[K\u001b[18;81H"}
|
||||||
|
{"keyAt":true,"data":"e"}
|
||||||
|
{"delayMs":27,"data":"e\u001b[K\u001b[20;80H\u001b[K\u001b[18;82H"}
|
||||||
|
{"keyAt":true,"data":"r"}
|
||||||
|
{"delayMs":27,"data":"r\u001b[K\u001b[20;80H\u001b[K\u001b[18;83H"}
|
||||||
|
{"keyAt":true,"data":" "}
|
||||||
|
{"delayMs":16,"data":"\u001b[K\u001b[20;80H\u001b[K\u001b[18;84H"}
|
||||||
|
{"keyAt":true,"data":"b"}
|
||||||
|
{"delayMs":30,"data":"b\u001b[K\u001b[20;80H\u001b[K\u001b[18;85H"}
|
||||||
|
{"keyAt":true,"data":"o"}
|
||||||
|
{"delayMs":27,"data":"o\u001b[K\u001b[20;80H\u001b[K\u001b[18;86H"}
|
||||||
|
{"keyAt":true,"data":"x"}
|
||||||
|
{"delayMs":27,"data":"x\u001b[K\u001b[20;80H\u001b[K\u001b[18;87H"}
|
||||||
|
{"keyAt":true,"data":" "}
|
||||||
|
{"delayMs":26,"data":"\u001b[K\u001b[20;80H\u001b[K\u001b[18;88H"}
|
||||||
|
{"keyAt":true,"data":"h"}
|
||||||
|
{"delayMs":17,"data":"h\u001b[K\u001b[20;80H\u001b[K\u001b[18;89H"}
|
||||||
|
{"keyAt":true,"data":"a"}
|
||||||
|
{"delayMs":28,"data":"a\u001b[K\u001b[20;80H\u001b[K\u001b[18;90H"}
|
||||||
|
{"keyAt":true,"data":"s"}
|
||||||
|
{"delayMs":27,"data":"s\u001b[K\u001b[20;80H\u001b[K\u001b[18;91H"}
|
||||||
|
{"keyAt":true,"data":" "}
|
||||||
|
{"delayMs":28,"data":"\u001b[K\u001b[20;80H\u001b[K\u001b[18;92H"}
|
||||||
|
{"keyAt":true,"data":"t"}
|
||||||
|
{"delayMs":26,"data":"t\u001b[K\u001b[20;80H\u001b[K\u001b[18;93H"}
|
||||||
|
{"keyAt":true,"data":"o"}
|
||||||
|
{"keyAt":true,"data":" "}
|
||||||
|
{"delayMs":27,"data":"o\u001b[K\u001b[20;80H\u001b[K\u001b[18;94H"}
|
||||||
|
{"delayMs":16,"data":"\u001b[K\u001b[20;80H\u001b[K\u001b[18;95H"}
|
||||||
|
{"keyAt":true,"data":"w"}
|
||||||
|
{"delayMs":30,"data":"w\u001b[K\u001b[20;80H\u001b[K\u001b[18;96H"}
|
||||||
|
{"keyAt":true,"data":"r"}
|
||||||
|
{"delayMs":27,"data":"r\u001b[K\u001b[20;80H\u001b[K\u001b[18;97H"}
|
||||||
|
{"keyAt":true,"data":"a"}
|
||||||
|
{"delayMs":26,"data":"a\u001b[K\u001b[20;80H\u001b[K\u001b[18;98H"}
|
||||||
|
{"keyAt":true,"data":"p"}
|
||||||
|
{"delayMs":27,"data":"p\u001b[K\u001b[20;80H\u001b[K\u001b[18;99H"}
|
||||||
|
{"keyAt":true,"data":" "}
|
||||||
|
{"delayMs":27,"data":"\u001b[17;1H\u001b[J\u001b[A\u001b[K"}
|
||||||
|
{"keyAt":true,"data":"t"}
|
||||||
|
{"delayMs":1,"data":"\u001b[2B\u001b[1m›\u001b[C\u001b(B\u001b[mthe\u001b[Cquick\u001b[Cbrown\u001b[Cfox\u001b[Cjumps\u001b[Cover\u001b[Cthe\u001b[Clazy\u001b[Cdog\u001b[Cand\u001b[Ckeeps\u001b[Crunning\u001b[Cuntil\u001b[Cthe\u001b[Ccomposer\u001b[Cbox\u001b[Chas\u001b[Cto\u001b[Cwrap\u001b[21;3H\u001b[38;5;223mgpt-5.6-sol default\u001b[39m\u001b[2m · \u001b(B\u001b[m\u001b[38;5;151m~/default/claudeman-predictive/tmp/codexrec-work-VGU83J\u001b[19;3H\u001b(B\u001b[m"}
|
||||||
|
{"keyAt":true,"data":"h"}
|
||||||
|
{"delayMs":42,"data":"\u001b[18;99H\u001b[K\u001b[19;3Hth\u001b[21;80H\u001b[K\u001b[19;5H"}
|
||||||
|
{"keyAt":true,"data":"i"}
|
||||||
|
{"delayMs":29,"data":"\u001b[18;99H\u001b[K\u001b[19;5Hi\u001b[K\u001b[21;80H\u001b[K\u001b[19;6H"}
|
||||||
|
{"keyAt":true,"data":"s"}
|
||||||
|
{"delayMs":27,"data":"\u001b[18;99H\u001b[K\u001b[19;6Hs\u001b[K\u001b[21;80H\u001b[K\u001b[19;7H"}
|
||||||
|
{"keyAt":true,"data":" "}
|
||||||
|
{"delayMs":27,"data":"\u001b[18;99H\u001b[K\u001b[19;7H\u001b[K\u001b[21;80H\u001b[K\u001b[19;8H"}
|
||||||
|
{"keyAt":true,"data":"l"}
|
||||||
|
{"delayMs":26,"data":"\u001b[18;99H\u001b[K\u001b[19;8Hl\u001b[K\u001b[21;80H\u001b[K\u001b[19;9H"}
|
||||||
|
{"keyAt":true,"data":"i"}
|
||||||
|
{"keyAt":true,"data":"n"}
|
||||||
|
{"delayMs":45,"data":"\u001b[18;99H\u001b[K\u001b[19;9Hin\u001b[K\u001b[21;80H\u001b[K\u001b[19;11H"}
|
||||||
|
{"keyAt":true,"data":"e"}
|
||||||
|
{"delayMs":27,"data":"\u001b[18;99H\u001b[K\u001b[19;11He\u001b[K\u001b[21;80H\u001b[K\u001b[19;12H"}
|
||||||
|
{"keyAt":true,"data":" "}
|
||||||
|
{"delayMs":27,"data":"\u001b[18;99H\u001b[K\u001b[19;12H\u001b[K\u001b[21;80H\u001b[K\u001b[19;13H"}
|
||||||
|
{"keyAt":true,"data":"t"}
|
||||||
|
{"delayMs":27,"data":"\u001b[18;99H\u001b[K\u001b[19;13Ht\u001b[K\u001b[21;80H\u001b[K\u001b[19;14H"}
|
||||||
|
{"keyAt":true,"data":"w"}
|
||||||
|
{"keyAt":true,"data":"i"}
|
||||||
|
{"delayMs":45,"data":"\u001b[18;99H\u001b[K\u001b[19;14Hwi\u001b[K\u001b[21;80H\u001b[K\u001b[19;16H"}
|
||||||
|
{"keyAt":true,"data":"c"}
|
||||||
|
{"delayMs":28,"data":"\u001b[18;99H\u001b[K\u001b[19;16Hc\u001b[K\u001b[21;80H\u001b[K\u001b[19;17H"}
|
||||||
|
{"keyAt":true,"data":"e"}
|
||||||
|
{"delayMs":26,"data":"\u001b[18;99H\u001b[K\u001b[19;17He\u001b[K\u001b[21;80H\u001b[K\u001b[19;18H"}
|
||||||
|
{"keyAt":true,"data":" "}
|
||||||
|
{"keyAt":true,"data":"o"}
|
||||||
|
{"delayMs":28,"data":"\u001b[18;99H\u001b[K\u001b[19;18H\u001b[K\u001b[21;80H\u001b[K\u001b[19;19H"}
|
||||||
|
{"keyAt":true,"data":"v"}
|
||||||
|
{"delayMs":26,"data":"\u001b[18;99H\u001b[K\u001b[19;19Ho\u001b[K\u001b[21;80H\u001b[K\u001b[19;20H"}
|
||||||
|
{"keyAt":true,"data":"e"}
|
||||||
|
{"delayMs":26,"data":"\u001b[18;99H\u001b[K\u001b[19;20Hv\u001b[K\u001b[21;80H\u001b[K\u001b[19;21H"}
|
||||||
|
{"delayMs":17,"data":"\u001b[18;99H\u001b[K\u001b[19;21He\u001b[K\u001b[21;80H\u001b[K\u001b[19;22H"}
|
||||||
|
{"keyAt":true,"data":"r"}
|
||||||
|
{"delayMs":29,"data":"\u001b[18;99H\u001b[K\u001b[19;22Hr\u001b[K\u001b[21;80H\u001b[K\u001b[19;23H"}
|
||||||
@@ -3,129 +3,204 @@
|
|||||||
*
|
*
|
||||||
* Creates a minimal Terminal-like object that satisfies the addon's
|
* Creates a minimal Terminal-like object that satisfies the addon's
|
||||||
* requirements without needing a real xterm.js instance or DOM renderer.
|
* requirements without needing a real xterm.js instance or DOM renderer.
|
||||||
|
*
|
||||||
|
* PredictiveEchoAddon additions (all ADDITIVE, existing tests unchanged):
|
||||||
|
* mutable cursor via setCursor(), wide-char-aware getCell() on mock lines,
|
||||||
|
* onWriteParsed/onResize emitters with fire* triggers, and opt-outs for
|
||||||
|
* getCell support and the emitters (getCellSupport / emitters options).
|
||||||
*/
|
*/
|
||||||
|
import { charCellWidth } from '../src/overlay-renderer.js';
|
||||||
|
|
||||||
interface MockLine {
|
interface MockLine {
|
||||||
translateToString(_trimRight?: boolean): string;
|
translateToString(_trimRight?: boolean): string;
|
||||||
|
getCell?(x: number): { getChars(): string; getWidth(): number } | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MockBufferOptions {
|
interface MockBufferOptions {
|
||||||
lines: string[];
|
lines: string[];
|
||||||
viewportY?: number;
|
viewportY?: number;
|
||||||
baseY?: number;
|
baseY?: number;
|
||||||
cursorX?: number;
|
cursorX?: number;
|
||||||
cursorY?: number;
|
cursorY?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MockTerminalOptions {
|
interface MockTerminalOptions {
|
||||||
buffer?: MockBufferOptions;
|
buffer?: MockBufferOptions;
|
||||||
cols?: number;
|
cols?: number;
|
||||||
rows?: number;
|
rows?: number;
|
||||||
fontFamily?: string;
|
fontFamily?: string;
|
||||||
fontSize?: number;
|
fontSize?: number;
|
||||||
fontWeight?: string | number;
|
fontWeight?: string | number;
|
||||||
theme?: {
|
theme?: {
|
||||||
background?: string;
|
background?: string;
|
||||||
foreground?: string;
|
foreground?: string;
|
||||||
cursor?: string;
|
cursor?: string;
|
||||||
};
|
};
|
||||||
cellWidth?: number;
|
cellWidth?: number;
|
||||||
cellHeight?: number;
|
cellHeight?: number;
|
||||||
/** Device-pixel char top offset (for charTop calculation). Default: 0 */
|
/** Device-pixel char top offset (for charTop calculation). Default: 0 */
|
||||||
deviceCharTop?: number;
|
deviceCharTop?: number;
|
||||||
/** Device-pixel char height (for charHeight calculation). Default: cellHeight * dpr */
|
/** Device-pixel char height (for charHeight calculation). Default: cellHeight * dpr */
|
||||||
deviceCharHeight?: number;
|
deviceCharHeight?: number;
|
||||||
|
/** Provide getCell() on mock lines (PredictiveEchoAddon). Default: true */
|
||||||
|
getCellSupport?: boolean;
|
||||||
|
/** Provide onWriteParsed/onResize emitters (PredictiveEchoAddon). Default: true */
|
||||||
|
emitters?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Column-indexed cell access over a plain string, wide-char aware. */
|
||||||
|
function cellAt(text: string, col: number): { getChars(): string; getWidth(): number } {
|
||||||
|
let c = 0;
|
||||||
|
for (const ch of text) {
|
||||||
|
const w = charCellWidth(null, ch);
|
||||||
|
if (col === c) return { getChars: () => ch, getWidth: () => w };
|
||||||
|
if (w === 2 && col === c + 1) return { getChars: () => '', getWidth: () => 0 };
|
||||||
|
c += w;
|
||||||
|
}
|
||||||
|
return { getChars: () => '', getWidth: () => 1 };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createMockTerminal(opts: MockTerminalOptions = {}) {
|
export function createMockTerminal(opts: MockTerminalOptions = {}) {
|
||||||
const bufOpts = opts.buffer ?? { lines: ['$ '] };
|
const bufOpts = opts.buffer ?? { lines: ['$ '] };
|
||||||
const lines = bufOpts.lines;
|
const viewportY = bufOpts.viewportY ?? 0;
|
||||||
const viewportY = bufOpts.viewportY ?? 0;
|
const baseY = bufOpts.baseY ?? viewportY;
|
||||||
const baseY = bufOpts.baseY ?? viewportY;
|
const cols = opts.cols ?? 80;
|
||||||
const cols = opts.cols ?? 80;
|
const rows = opts.rows ?? Math.max(bufOpts.lines.length, 24);
|
||||||
const rows = opts.rows ?? Math.max(lines.length, 24);
|
const cellW = opts.cellWidth ?? 8.4;
|
||||||
const cellW = opts.cellWidth ?? 8.4;
|
const cellH = opts.cellHeight ?? 17;
|
||||||
const cellH = opts.cellHeight ?? 17;
|
const getCellSupport = opts.getCellSupport ?? true;
|
||||||
|
const emitters = opts.emitters ?? true;
|
||||||
|
|
||||||
const mockLines: MockLine[] = lines.map((text) => ({
|
const makeLine = (text: string): { line: MockLine; set(t: string): void } => {
|
||||||
translateToString: () => text,
|
let current = text;
|
||||||
}));
|
const line: MockLine = {
|
||||||
|
translateToString: () => current,
|
||||||
// Create minimal DOM structure
|
|
||||||
const element = document.createElement('div');
|
|
||||||
element.className = 'terminal xterm';
|
|
||||||
|
|
||||||
const viewport = document.createElement('div');
|
|
||||||
viewport.className = 'xterm-viewport';
|
|
||||||
|
|
||||||
const screen = document.createElement('div');
|
|
||||||
screen.className = 'xterm-screen';
|
|
||||||
screen.style.position = 'relative';
|
|
||||||
|
|
||||||
const xtermRows = document.createElement('div');
|
|
||||||
xtermRows.className = 'xterm-rows';
|
|
||||||
|
|
||||||
element.appendChild(viewport);
|
|
||||||
element.appendChild(screen);
|
|
||||||
screen.appendChild(xtermRows);
|
|
||||||
|
|
||||||
// Append to document so getComputedStyle works
|
|
||||||
document.body.appendChild(element);
|
|
||||||
|
|
||||||
const terminal = {
|
|
||||||
element,
|
|
||||||
cols,
|
|
||||||
rows,
|
|
||||||
options: {
|
|
||||||
fontFamily: opts.fontFamily ?? 'monospace',
|
|
||||||
fontSize: opts.fontSize ?? 14,
|
|
||||||
fontWeight: opts.fontWeight ?? 'normal',
|
|
||||||
theme: opts.theme ?? {},
|
|
||||||
},
|
|
||||||
buffer: {
|
|
||||||
active: {
|
|
||||||
viewportY,
|
|
||||||
baseY,
|
|
||||||
cursorX: bufOpts.cursorX ?? 0,
|
|
||||||
cursorY: bufOpts.cursorY ?? 0,
|
|
||||||
getLine: (absRow: number): MockLine | undefined => {
|
|
||||||
return mockLines[absRow - viewportY];
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
_core: {
|
|
||||||
_renderService: {
|
|
||||||
dimensions: {
|
|
||||||
css: {
|
|
||||||
cell: { width: cellW, height: cellH },
|
|
||||||
},
|
|
||||||
device: {
|
|
||||||
char: {
|
|
||||||
top: opts.deviceCharTop ?? 0,
|
|
||||||
height: opts.deviceCharHeight ?? cellH,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
// Simulate loadAddon
|
|
||||||
loadAddon(addon: { activate: (t: unknown) => void }) {
|
|
||||||
addon.activate(this);
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
if (getCellSupport) {
|
||||||
|
line.getCell = (x: number) => cellAt(current, x);
|
||||||
|
}
|
||||||
|
return { line, set: (t: string) => (current = t) };
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
let mockLines = bufOpts.lines.map(makeLine);
|
||||||
terminal,
|
|
||||||
/** Update buffer lines for subsequent calls */
|
// Create minimal DOM structure
|
||||||
setLines(newLines: string[]) {
|
const element = document.createElement('div');
|
||||||
mockLines.length = 0;
|
element.className = 'terminal xterm';
|
||||||
for (const text of newLines) {
|
|
||||||
mockLines.push({ translateToString: () => text });
|
const viewport = document.createElement('div');
|
||||||
}
|
viewport.className = 'xterm-viewport';
|
||||||
|
|
||||||
|
const screen = document.createElement('div');
|
||||||
|
screen.className = 'xterm-screen';
|
||||||
|
screen.style.position = 'relative';
|
||||||
|
|
||||||
|
const xtermRows = document.createElement('div');
|
||||||
|
xtermRows.className = 'xterm-rows';
|
||||||
|
|
||||||
|
element.appendChild(viewport);
|
||||||
|
element.appendChild(screen);
|
||||||
|
screen.appendChild(xtermRows);
|
||||||
|
|
||||||
|
// Append to document so getComputedStyle works
|
||||||
|
document.body.appendChild(element);
|
||||||
|
|
||||||
|
const writeParsedCbs = new Set<() => void>();
|
||||||
|
const resizeCbs = new Set<(s: { cols: number; rows: number }) => void>();
|
||||||
|
|
||||||
|
const terminal = {
|
||||||
|
element,
|
||||||
|
cols,
|
||||||
|
rows,
|
||||||
|
options: {
|
||||||
|
fontFamily: opts.fontFamily ?? 'monospace',
|
||||||
|
fontSize: opts.fontSize ?? 14,
|
||||||
|
fontWeight: opts.fontWeight ?? 'normal',
|
||||||
|
theme: opts.theme ?? {},
|
||||||
|
},
|
||||||
|
buffer: {
|
||||||
|
active: {
|
||||||
|
viewportY,
|
||||||
|
baseY,
|
||||||
|
cursorX: bufOpts.cursorX ?? 0,
|
||||||
|
cursorY: bufOpts.cursorY ?? 0,
|
||||||
|
getLine: (absRow: number): MockLine | undefined => {
|
||||||
|
return mockLines[absRow - viewportY]?.line;
|
||||||
},
|
},
|
||||||
/** Clean up DOM */
|
},
|
||||||
cleanup() {
|
},
|
||||||
element.remove();
|
_core: {
|
||||||
|
_renderService: {
|
||||||
|
dimensions: {
|
||||||
|
css: {
|
||||||
|
cell: { width: cellW, height: cellH },
|
||||||
|
},
|
||||||
|
device: {
|
||||||
|
char: {
|
||||||
|
top: opts.deviceCharTop ?? 0,
|
||||||
|
height: opts.deviceCharHeight ?? cellH,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
},
|
||||||
|
},
|
||||||
|
...(emitters
|
||||||
|
? {
|
||||||
|
onWriteParsed(cb: () => void) {
|
||||||
|
writeParsedCbs.add(cb);
|
||||||
|
return { dispose: () => writeParsedCbs.delete(cb) };
|
||||||
|
},
|
||||||
|
onResize(cb: (s: { cols: number; rows: number }) => void) {
|
||||||
|
resizeCbs.add(cb);
|
||||||
|
return { dispose: () => resizeCbs.delete(cb) };
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
// Simulate loadAddon
|
||||||
|
loadAddon(addon: { activate: (t: unknown) => void }) {
|
||||||
|
addon.activate(this);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
terminal,
|
||||||
|
/** Update buffer lines for subsequent calls */
|
||||||
|
setLines(newLines: string[]) {
|
||||||
|
mockLines = newLines.map(makeLine);
|
||||||
|
},
|
||||||
|
/** Update one line's text in place (PredictiveEchoAddon echo simulation) */
|
||||||
|
setLine(index: number, text: string) {
|
||||||
|
mockLines[index]?.set(text);
|
||||||
|
},
|
||||||
|
/** Move the mock cursor (PredictiveEchoAddon) */
|
||||||
|
setCursor(x: number, y: number) {
|
||||||
|
terminal.buffer.active.cursorX = x;
|
||||||
|
terminal.buffer.active.cursorY = y;
|
||||||
|
},
|
||||||
|
/** Set scroll state (viewportY / baseY) */
|
||||||
|
setScroll(newViewportY: number, newBaseY: number) {
|
||||||
|
terminal.buffer.active.viewportY = newViewportY;
|
||||||
|
terminal.buffer.active.baseY = newBaseY;
|
||||||
|
},
|
||||||
|
/** Fire the onWriteParsed emitter (PredictiveEchoAddon reconcile trigger) */
|
||||||
|
fireWriteParsed() {
|
||||||
|
for (const cb of [...writeParsedCbs]) cb();
|
||||||
|
},
|
||||||
|
/** Fire the onResize emitter */
|
||||||
|
fireResize(newCols = cols, newRows = rows) {
|
||||||
|
for (const cb of [...resizeCbs]) cb({ cols: newCols, rows: newRows });
|
||||||
|
},
|
||||||
|
/** Number of live onWriteParsed listeners (dispose assertions) */
|
||||||
|
writeParsedListenerCount() {
|
||||||
|
return writeParsedCbs.size;
|
||||||
|
},
|
||||||
|
/** Number of live onResize listeners (dispose assertions) */
|
||||||
|
resizeListenerCount() {
|
||||||
|
return resizeCbs.size;
|
||||||
|
},
|
||||||
|
/** Clean up DOM */
|
||||||
|
cleanup() {
|
||||||
|
element.remove();
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
/**
|
||||||
|
* @vitest-environment jsdom
|
||||||
|
*
|
||||||
|
* prediction-renderer unit tests: span geometry math, seam-cover height,
|
||||||
|
* ligature suppression, incremental add/remove keyed by seq, and geometry
|
||||||
|
* stability under a non-1 devicePixelRatio (all dims are CSS px).
|
||||||
|
*/
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { addPredictionSpan, clearAllSpans, removePredictionSpan } from '../src/prediction-renderer.js';
|
||||||
|
import type { CellDimensions, FontStyle } from '../src/types.js';
|
||||||
|
|
||||||
|
const dims: CellDimensions = { width: 9, height: 18, charTop: 1, charHeight: 16 };
|
||||||
|
const font: FontStyle = {
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 'normal',
|
||||||
|
color: '#e0e0e0',
|
||||||
|
backgroundColor: '#101010',
|
||||||
|
letterSpacing: '0.5px',
|
||||||
|
};
|
||||||
|
|
||||||
|
function makeContainer() {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
document.body.appendChild(el);
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
function span(container: HTMLElement, map: Map<number, HTMLSpanElement>, over: Record<string, unknown> = {}) {
|
||||||
|
addPredictionSpan(container, map, {
|
||||||
|
seq: 1,
|
||||||
|
row: 3,
|
||||||
|
col: 5,
|
||||||
|
char: 'x',
|
||||||
|
width: 1,
|
||||||
|
dims,
|
||||||
|
font,
|
||||||
|
underline: false,
|
||||||
|
...over,
|
||||||
|
} as never);
|
||||||
|
return map.get((over.seq as number) ?? 1)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('prediction-renderer', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('positions a width-1 span on the exact cell grid', () => {
|
||||||
|
const map = new Map<number, HTMLSpanElement>();
|
||||||
|
const s = span(makeContainer(), map);
|
||||||
|
expect(s.style.left).toBe(`${5 * 9}px`);
|
||||||
|
expect(s.style.top).toBe(`${3 * 18}px`);
|
||||||
|
expect(s.style.width).toBe(`${9}px`);
|
||||||
|
expect(s.textContent).toBe('x');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('positions a width-2 span across two cells', () => {
|
||||||
|
const map = new Map<number, HTMLSpanElement>();
|
||||||
|
const s = span(makeContainer(), map, { char: '你', width: 2 });
|
||||||
|
expect(s.style.width).toBe(`${2 * 9}px`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('covers the row seam: height is cellH+1 with line-height cellH', () => {
|
||||||
|
const map = new Map<number, HTMLSpanElement>();
|
||||||
|
const s = span(makeContainer(), map);
|
||||||
|
expect(s.style.height).toBe(`${18 + 1}px`);
|
||||||
|
expect(s.style.lineHeight).toBe('18px');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disables ligatures and pointer events, applies font + letter-spacing', () => {
|
||||||
|
const map = new Map<number, HTMLSpanElement>();
|
||||||
|
const s = span(makeContainer(), map);
|
||||||
|
expect(s.style.cssText).toContain("'liga' 0");
|
||||||
|
expect(s.style.cssText).toContain("'calt' 0");
|
||||||
|
expect(s.style.pointerEvents).toBe('none');
|
||||||
|
expect(s.style.fontFamily).toBe('monospace');
|
||||||
|
expect(s.style.letterSpacing).toBe('0.5px');
|
||||||
|
expect(s.style.textAlign).toBe('center');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('paints an opaque background over only its own cells', () => {
|
||||||
|
const map = new Map<number, HTMLSpanElement>();
|
||||||
|
const s = span(makeContainer(), map);
|
||||||
|
expect(['#101010', 'rgb(16, 16, 16)']).toContain(s.style.backgroundColor);
|
||||||
|
// Background is bounded by the span's own width, never a full row
|
||||||
|
expect(s.style.width).toBe('9px');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('underline renders only when requested', () => {
|
||||||
|
const map = new Map<number, HTMLSpanElement>();
|
||||||
|
const container = makeContainer();
|
||||||
|
const plain = span(container, map, { seq: 1 });
|
||||||
|
const lined = span(container, map, { seq: 2, underline: true });
|
||||||
|
expect(plain.style.textDecoration).toBe('');
|
||||||
|
expect(lined.style.textDecoration).toBe('underline');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds and removes incrementally, keyed by seq', () => {
|
||||||
|
const map = new Map<number, HTMLSpanElement>();
|
||||||
|
const container = makeContainer();
|
||||||
|
span(container, map, { seq: 1 });
|
||||||
|
span(container, map, { seq: 2, col: 6 });
|
||||||
|
span(container, map, { seq: 3, col: 7 });
|
||||||
|
expect(container.children).toHaveLength(3);
|
||||||
|
|
||||||
|
removePredictionSpan(map, 2);
|
||||||
|
expect(container.children).toHaveLength(2);
|
||||||
|
expect(map.has(2)).toBe(false);
|
||||||
|
expect(map.has(1)).toBe(true);
|
||||||
|
expect(map.has(3)).toBe(true);
|
||||||
|
|
||||||
|
removePredictionSpan(map, 999); // unknown seq: no-op
|
||||||
|
expect(container.children).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clearAllSpans empties both the DOM and the map', () => {
|
||||||
|
const map = new Map<number, HTMLSpanElement>();
|
||||||
|
const container = makeContainer();
|
||||||
|
span(container, map, { seq: 1 });
|
||||||
|
span(container, map, { seq: 2, col: 6 });
|
||||||
|
clearAllSpans(map);
|
||||||
|
expect(container.children).toHaveLength(0);
|
||||||
|
expect(map.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('geometry is stable under devicePixelRatio 2 (dims are CSS px)', () => {
|
||||||
|
vi.stubGlobal('devicePixelRatio', 2);
|
||||||
|
const map = new Map<number, HTMLSpanElement>();
|
||||||
|
const s = span(makeContainer(), map);
|
||||||
|
expect(s.style.left).toBe(`${5 * 9}px`);
|
||||||
|
expect(s.style.top).toBe(`${3 * 18}px`);
|
||||||
|
expect(s.style.width).toBe('9px');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,538 @@
|
|||||||
|
/**
|
||||||
|
* @vitest-environment jsdom
|
||||||
|
*
|
||||||
|
* PredictiveEchoAddon unit tests: the algorithm laws (anchoring, prefix-only
|
||||||
|
* confirmation with cursor advance, two-pass mismatch cascade with neutral
|
||||||
|
* blanks, TTL, off-row grace, gates) and lifecycle safety.
|
||||||
|
*
|
||||||
|
* Timer-based cases fake `performance` explicitly: the addon clocks
|
||||||
|
* sentAt/TTL/grace with performance.now(), which vitest does NOT fake by
|
||||||
|
* default.
|
||||||
|
*/
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { PredictiveEchoAddon } from '../src/predictive-echo-addon.js';
|
||||||
|
import { createMockTerminal } from './helpers.js';
|
||||||
|
|
||||||
|
const TIMER_CONFIG = {
|
||||||
|
toFake: ['setTimeout', 'clearTimeout', 'setInterval', 'clearInterval', 'Date', 'performance'] as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Composer-like buffer: `› ` marker + placeholder, cursor at col 2 row 0. */
|
||||||
|
function composerMock(opts: Parameters<typeof createMockTerminal>[0] = {}) {
|
||||||
|
return createMockTerminal({
|
||||||
|
buffer: { lines: ['› Use /skills to list', '', ''], cursorX: 2, cursorY: 0 },
|
||||||
|
...opts,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function spansOf(mock: ReturnType<typeof createMockTerminal>): HTMLSpanElement[] {
|
||||||
|
const screen = mock.terminal.element.querySelector('.xterm-screen')!;
|
||||||
|
return Array.from(screen.querySelectorAll('[data-predictive-echo] span')) as HTMLSpanElement[];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function flushMicrotasks() {
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('PredictiveEchoAddon', () => {
|
||||||
|
let mock: ReturnType<typeof createMockTerminal>;
|
||||||
|
let addon: PredictiveEchoAddon;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers(TIMER_CONFIG);
|
||||||
|
mock = composerMock();
|
||||||
|
addon = new PredictiveEchoAddon();
|
||||||
|
addon.activate(mock.terminal as never);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
addon.dispose();
|
||||||
|
mock.cleanup();
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('paints a span at the cursor cell and returns true', () => {
|
||||||
|
expect(addon.predictChar('h')).toBe(true);
|
||||||
|
const spans = spansOf(mock);
|
||||||
|
expect(spans).toHaveLength(1);
|
||||||
|
expect(spans[0].textContent).toBe('h');
|
||||||
|
expect(spans[0].style.left).toBe(`${2 * 8.4}px`);
|
||||||
|
expect(spans[0].style.top).toBe('0px');
|
||||||
|
expect(addon.state.outstanding).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stacks predictions at anchor+cumulative width while the cursor is unmoved', () => {
|
||||||
|
addon.predictChar('h');
|
||||||
|
addon.predictChar('e');
|
||||||
|
addon.predictChar('y');
|
||||||
|
const spans = spansOf(mock);
|
||||||
|
expect(spans.map((s) => s.style.left)).toEqual([`${2 * 8.4}px`, `${3 * 8.4}px`, `${4 * 8.4}px`]);
|
||||||
|
expect(addon.state.anchor).toEqual({ row: 0, col: 2 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('re-anchors at the new cursor once outstanding drains to zero', async () => {
|
||||||
|
addon.predictChar('h');
|
||||||
|
mock.setLine(0, '› h');
|
||||||
|
mock.setCursor(3, 0);
|
||||||
|
mock.fireWriteParsed();
|
||||||
|
await flushMicrotasks();
|
||||||
|
expect(addon.state.outstanding).toBe(0);
|
||||||
|
expect(addon.state.anchor).toBeNull();
|
||||||
|
|
||||||
|
addon.predictChar('i');
|
||||||
|
expect(addon.state.anchor).toEqual({ row: 0, col: 3 });
|
||||||
|
expect(spansOf(mock)[0].style.left).toBe(`${3 * 8.4}px`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('inline reconcile inside predictChar absorbs an echo that landed between keystrokes', () => {
|
||||||
|
addon.predictChar('h');
|
||||||
|
// Echo lands but no onWriteParsed fires before the next keystroke
|
||||||
|
mock.setLine(0, '› h');
|
||||||
|
mock.setCursor(3, 0);
|
||||||
|
expect(addon.predictChar('i')).toBe(true);
|
||||||
|
// 'h' confirmed inline; 'i' anchored at the advanced cursor, not stacked
|
||||||
|
expect(addon.state.outstanding).toBe(1);
|
||||||
|
expect(addon.state.confirmedTotal).toBe(1);
|
||||||
|
expect(addon.state.anchor).toEqual({ row: 0, col: 3 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('confirms and removes exactly the echoed prefix (cell match + cursor advance)', async () => {
|
||||||
|
addon.predictChar('a');
|
||||||
|
addon.predictChar('b');
|
||||||
|
addon.predictChar('c');
|
||||||
|
mock.setLine(0, '› ab');
|
||||||
|
mock.setCursor(4, 0); // advanced past 'a' and 'b' only
|
||||||
|
mock.fireWriteParsed();
|
||||||
|
await flushMicrotasks();
|
||||||
|
expect(addon.state.confirmedTotal).toBe(2);
|
||||||
|
expect(addon.state.outstanding).toBe(1);
|
||||||
|
expect(spansOf(mock).map((s) => s.textContent)).toEqual(['c']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('partial confirmation never moves remaining spans (no jitter)', async () => {
|
||||||
|
addon.predictChar('a');
|
||||||
|
addon.predictChar('b');
|
||||||
|
const bLeft = spansOf(mock)[1].style.left;
|
||||||
|
mock.setLine(0, '› a');
|
||||||
|
mock.setCursor(3, 0);
|
||||||
|
mock.fireWriteParsed();
|
||||||
|
await flushMicrotasks();
|
||||||
|
expect(spansOf(mock)).toHaveLength(1);
|
||||||
|
expect(spansOf(mock)[0].style.left).toBe(bLeft);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does NOT confirm when the cell matches but the cursor has not advanced (in-place repaint)', async () => {
|
||||||
|
// Predict 'U' over the placeholder whose cell already shows 'U'
|
||||||
|
addon.predictChar('U');
|
||||||
|
expect(addon.state.outstanding).toBe(1);
|
||||||
|
// tmux repaints the identical row; cursor stays at the anchor
|
||||||
|
mock.fireWriteParsed();
|
||||||
|
await flushMicrotasks();
|
||||||
|
expect(addon.state.outstanding).toBe(1);
|
||||||
|
expect(addon.state.confirmedTotal).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does NOT confirm or drop when the predicted char equals the pre-existing snapshot', async () => {
|
||||||
|
addon.predictChar('U');
|
||||||
|
// Several passes over the unchanged placeholder: no confirm, no cascade
|
||||||
|
for (let i = 0; i < 4; i++) {
|
||||||
|
mock.fireWriteParsed();
|
||||||
|
await flushMicrotasks();
|
||||||
|
}
|
||||||
|
expect(addon.state.outstanding).toBe(1);
|
||||||
|
expect(addon.state.droppedTotal).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('one transient mismatch survives; a persistent foreign cell cascades (two-pass rule)', async () => {
|
||||||
|
addon.predictChar('a');
|
||||||
|
mock.setLine(0, '› Z'); // foreign non-blank at the predicted cell
|
||||||
|
mock.fireWriteParsed();
|
||||||
|
await flushMicrotasks();
|
||||||
|
expect(addon.state.outstanding).toBe(1); // pass 1: survives
|
||||||
|
|
||||||
|
// Transient recovery resets the counter
|
||||||
|
mock.setLine(0, '› Use /skills to list');
|
||||||
|
mock.fireWriteParsed();
|
||||||
|
await flushMicrotasks();
|
||||||
|
mock.setLine(0, '› Z');
|
||||||
|
mock.fireWriteParsed();
|
||||||
|
await flushMicrotasks();
|
||||||
|
expect(addon.state.outstanding).toBe(1); // count restarted, pass 1 again
|
||||||
|
|
||||||
|
mock.fireWriteParsed();
|
||||||
|
await flushMicrotasks();
|
||||||
|
expect(addon.state.outstanding).toBe(0); // pass 2: cascaded
|
||||||
|
expect(addon.state.droppedTotal).toBe(1);
|
||||||
|
expect(spansOf(mock)).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blank cells are neutral: placeholder cleared under predictions does not cascade', async () => {
|
||||||
|
// Predict over placeholder text, then codex clears the placeholder on
|
||||||
|
// first echo: later cells become blank, which must NOT count as
|
||||||
|
// foreign (measured behavior; without this, fast typing over the
|
||||||
|
// placeholder drops exactly when RTT is high).
|
||||||
|
addon.predictChar('h');
|
||||||
|
addon.predictChar('i');
|
||||||
|
mock.setLine(0, '› h'); // 'h' echoed; placeholder gone; 'i' cell now blank
|
||||||
|
mock.setCursor(3, 0);
|
||||||
|
for (let i = 0; i < 4; i++) {
|
||||||
|
mock.fireWriteParsed();
|
||||||
|
await flushMicrotasks();
|
||||||
|
}
|
||||||
|
expect(addon.state.confirmedTotal).toBe(1);
|
||||||
|
expect(addon.state.outstanding).toBe(1); // 'i' still pending, TTL-bounded
|
||||||
|
expect(addon.state.droppedTotal).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('mismatch cascade drops the record and all later ones, earlier confirmed stay gone', async () => {
|
||||||
|
addon.predictChar('a');
|
||||||
|
addon.predictChar('b');
|
||||||
|
addon.predictChar('c');
|
||||||
|
mock.setLine(0, '› aXX'); // 'a' echoed; foreign 'X' under 'b' and 'c'
|
||||||
|
mock.setCursor(3, 0);
|
||||||
|
mock.fireWriteParsed();
|
||||||
|
await flushMicrotasks();
|
||||||
|
mock.fireWriteParsed();
|
||||||
|
await flushMicrotasks();
|
||||||
|
expect(addon.state.confirmedTotal).toBe(1);
|
||||||
|
expect(addon.state.droppedTotal).toBe(2);
|
||||||
|
expect(addon.state.outstanding).toBe(0);
|
||||||
|
expect(spansOf(mock)).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TTL expiry drops predictions and leaves no timers armed (fake timers)', () => {
|
||||||
|
addon.predictChar('a');
|
||||||
|
addon.predictChar('b');
|
||||||
|
expect(vi.getTimerCount()).toBe(1);
|
||||||
|
vi.advanceTimersByTime(1100);
|
||||||
|
expect(addon.state.outstanding).toBe(0);
|
||||||
|
expect(addon.state.droppedTotal).toBe(2);
|
||||||
|
expect(spansOf(mock)).toHaveLength(0);
|
||||||
|
expect(vi.getTimerCount()).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TTL timer re-arms for remaining records after a partial confirm', async () => {
|
||||||
|
addon.predictChar('a'); // t=0, deadline ~1001
|
||||||
|
vi.advanceTimersByTime(600);
|
||||||
|
addon.predictChar('b'); // t=600, deadline ~1601
|
||||||
|
// Echo confirms 'a' before its TTL; 'b' remains
|
||||||
|
mock.setLine(0, '› a');
|
||||||
|
mock.setCursor(3, 0);
|
||||||
|
mock.fireWriteParsed();
|
||||||
|
await flushMicrotasks();
|
||||||
|
expect(addon.state.outstanding).toBe(1);
|
||||||
|
vi.advanceTimersByTime(450); // t=1050: a's timer fired, b (age 450) survives
|
||||||
|
expect(addon.state.outstanding).toBe(1);
|
||||||
|
expect(vi.getTimerCount()).toBe(1); // re-armed for b
|
||||||
|
vi.advanceTimersByTime(600); // t=1650: b expired
|
||||||
|
expect(addon.state.outstanding).toBe(0);
|
||||||
|
expect(vi.getTimerCount()).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cursor off anchor row within grace keeps predictions; sustained off-row drops all', async () => {
|
||||||
|
addon.predictChar('a');
|
||||||
|
mock.setCursor(0, 5);
|
||||||
|
mock.fireWriteParsed();
|
||||||
|
await flushMicrotasks();
|
||||||
|
expect(addon.state.outstanding).toBe(1); // transient excursion tolerated
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(200); // > cursorGraceMs (150)
|
||||||
|
mock.fireWriteParsed();
|
||||||
|
await flushMicrotasks();
|
||||||
|
expect(addon.state.outstanding).toBe(0);
|
||||||
|
expect(spansOf(mock)).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('viewportY !== baseY clears predictions (scrolled up)', async () => {
|
||||||
|
addon.predictChar('a');
|
||||||
|
mock.setScroll(0, 5); // user scrolled: viewport pinned above baseY
|
||||||
|
mock.fireWriteParsed();
|
||||||
|
await flushMicrotasks();
|
||||||
|
expect(addon.state.outstanding).toBe(0);
|
||||||
|
// And no new predictions while scrolled
|
||||||
|
expect(addon.predictChar('b')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maxPending: the 33rd predictChar returns false', () => {
|
||||||
|
for (let i = 0; i < 32; i++) {
|
||||||
|
expect(addon.predictChar('x')).toBe(true);
|
||||||
|
}
|
||||||
|
expect(addon.predictChar('y')).toBe(false);
|
||||||
|
expect(addon.state.outstanding).toBe(32);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('edge margin: a prediction landing within edgeMarginCells of cols returns false', () => {
|
||||||
|
mock.setCursor(75, 0); // cols 80, margin 4: col 75 + 1 <= 76 allowed
|
||||||
|
expect(addon.predictChar('a')).toBe(true);
|
||||||
|
// Next lands at col 76: 77 > 76 suppressed
|
||||||
|
expect(addon.predictChar('b')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('predictWhen gate false suppresses painting, predictChar just returns false', () => {
|
||||||
|
addon.setPredictWhen(() => false);
|
||||||
|
expect(addon.predictChar('a')).toBe(false);
|
||||||
|
expect(spansOf(mock)).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('setPredictWhen(null) removes the gate at runtime', () => {
|
||||||
|
addon.setPredictWhen(() => false);
|
||||||
|
expect(addon.predictChar('a')).toBe(false);
|
||||||
|
addon.setPredictWhen(null);
|
||||||
|
expect(addon.predictChar('a')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('multi-codepoint graphemes and control chars return false', () => {
|
||||||
|
for (const bad of ['ab', '\x1b', '\x03', '\r', '\n', '\t', '\x7f', '👨👩👧', '']) {
|
||||||
|
expect(addon.predictChar(bad)).toBe(false);
|
||||||
|
}
|
||||||
|
expect(spansOf(mock)).toHaveLength(0);
|
||||||
|
// Single astral emoji IS a single codepoint: predicted (width 2)
|
||||||
|
expect(addon.predictChar('😀')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('CJK: 2-cell span, next prediction offsets by 2, confirm reads the leading cell', async () => {
|
||||||
|
expect(addon.predictChar('你')).toBe(true);
|
||||||
|
const first = spansOf(mock)[0];
|
||||||
|
expect(first.style.width).toBe(`${2 * 8.4}px`);
|
||||||
|
addon.predictChar('a');
|
||||||
|
expect(spansOf(mock)[1].style.left).toBe(`${4 * 8.4}px`); // 2 + width 2
|
||||||
|
|
||||||
|
mock.setLine(0, '› 你');
|
||||||
|
mock.setCursor(4, 0); // advanced past the wide char
|
||||||
|
mock.fireWriteParsed();
|
||||||
|
await flushMicrotasks();
|
||||||
|
expect(addon.state.confirmedTotal).toBe(1);
|
||||||
|
expect(addon.state.outstanding).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getCell-less terminal: ASCII fallback works, wide chars suppressed', () => {
|
||||||
|
const bare = createMockTerminal({
|
||||||
|
buffer: { lines: ['› ', ''], cursorX: 2, cursorY: 0 },
|
||||||
|
getCellSupport: false,
|
||||||
|
});
|
||||||
|
const a = new PredictiveEchoAddon();
|
||||||
|
a.activate(bare.terminal as never);
|
||||||
|
expect(a.predictChar('x')).toBe(true);
|
||||||
|
expect(a.predictChar('你')).toBe(false);
|
||||||
|
a.dispose();
|
||||||
|
bare.cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("'' and ' ' cell reads are equivalent for snapshot and confirm", async () => {
|
||||||
|
// Snapshot beyond the line text reads '' -> normalized ' '
|
||||||
|
mock.setLine(0, '› ');
|
||||||
|
addon.predictChar('a'); // snapshot at col 2 is '' -> ' '
|
||||||
|
// A repaint that writes explicit spaces must not count as foreign
|
||||||
|
mock.setLine(0, '› ');
|
||||||
|
mock.fireWriteParsed();
|
||||||
|
await flushMicrotasks();
|
||||||
|
mock.fireWriteParsed();
|
||||||
|
await flushMicrotasks();
|
||||||
|
expect(addon.state.outstanding).toBe(1);
|
||||||
|
expect(addon.state.droppedTotal).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('predictBackspace pops newest, returns false when empty, never touches confirmed', async () => {
|
||||||
|
expect(addon.predictBackspace()).toBe(false);
|
||||||
|
addon.reconcile(); // the empty pop armed the anchor hold; release it
|
||||||
|
addon.predictChar('a');
|
||||||
|
addon.predictChar('b');
|
||||||
|
expect(addon.predictBackspace()).toBe(true);
|
||||||
|
expect(addon.state.outstanding).toBe(1);
|
||||||
|
expect(spansOf(mock).map((s) => s.textContent)).toEqual(['a']);
|
||||||
|
|
||||||
|
mock.setLine(0, '› a');
|
||||||
|
mock.setCursor(3, 0);
|
||||||
|
mock.fireWriteParsed();
|
||||||
|
await flushMicrotasks();
|
||||||
|
expect(addon.state.confirmedTotal).toBe(1);
|
||||||
|
expect(addon.predictBackspace()).toBe(false); // confirmed text is not popped
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clearPredictions empties the container, resets anchor, cancels the timer', () => {
|
||||||
|
addon.predictChar('a');
|
||||||
|
addon.predictChar('b');
|
||||||
|
expect(vi.getTimerCount()).toBe(1);
|
||||||
|
addon.clearPredictions();
|
||||||
|
expect(spansOf(mock)).toHaveLength(0);
|
||||||
|
expect(addon.state.outstanding).toBe(0);
|
||||||
|
expect(addon.state.anchor).toBeNull();
|
||||||
|
expect(vi.getTimerCount()).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('onWriteParsed reconcile is debounced to one pass per burst', async () => {
|
||||||
|
addon.predictChar('a');
|
||||||
|
mock.setLine(0, '› Z'); // foreign cell: each PASS increments mismatches
|
||||||
|
mock.fireWriteParsed();
|
||||||
|
mock.fireWriteParsed();
|
||||||
|
mock.fireWriteParsed();
|
||||||
|
await flushMicrotasks();
|
||||||
|
// Three synchronous fires coalesced into ONE pass: not dropped yet
|
||||||
|
expect(addon.state.outstanding).toBe(1);
|
||||||
|
mock.fireWriteParsed();
|
||||||
|
await flushMicrotasks();
|
||||||
|
expect(addon.state.outstanding).toBe(0); // second pass cascades
|
||||||
|
});
|
||||||
|
|
||||||
|
it('onResize clears predictions (cell geometry changed)', () => {
|
||||||
|
addon.predictChar('a');
|
||||||
|
mock.fireResize(120, 40);
|
||||||
|
expect(addon.state.outstanding).toBe(0);
|
||||||
|
expect(spansOf(mock)).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('works without onWriteParsed via manual reconcile()', () => {
|
||||||
|
const bare = composerMock({ emitters: false });
|
||||||
|
const a = new PredictiveEchoAddon();
|
||||||
|
a.activate(bare.terminal as never);
|
||||||
|
a.predictChar('h');
|
||||||
|
bare.setLine(0, '› h');
|
||||||
|
bare.setCursor(3, 0);
|
||||||
|
a.reconcile();
|
||||||
|
expect(a.state.confirmedTotal).toBe(1);
|
||||||
|
expect(a.state.outstanding).toBe(0);
|
||||||
|
a.dispose();
|
||||||
|
bare.cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dispose unhooks listeners and removes the container', () => {
|
||||||
|
expect(mock.writeParsedListenerCount()).toBe(1);
|
||||||
|
expect(mock.resizeListenerCount()).toBe(1);
|
||||||
|
addon.predictChar('a');
|
||||||
|
addon.dispose();
|
||||||
|
expect(mock.writeParsedListenerCount()).toBe(0);
|
||||||
|
expect(mock.resizeListenerCount()).toBe(0);
|
||||||
|
const screen = mock.terminal.element.querySelector('.xterm-screen')!;
|
||||||
|
expect(screen.querySelector('[data-predictive-echo]')).toBeNull();
|
||||||
|
expect(vi.getTimerCount()).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('every public method is safe before activate and after dispose', () => {
|
||||||
|
const fresh = new PredictiveEchoAddon();
|
||||||
|
expect(fresh.predictChar('a')).toBe(false);
|
||||||
|
expect(fresh.predictBackspace()).toBe(false);
|
||||||
|
fresh.clearPredictions();
|
||||||
|
fresh.reconcile();
|
||||||
|
fresh.refreshFont();
|
||||||
|
fresh.setPredictWhen(() => true);
|
||||||
|
expect(fresh.hasPredictions).toBe(false);
|
||||||
|
expect(fresh.state.outstanding).toBe(0);
|
||||||
|
|
||||||
|
addon.dispose();
|
||||||
|
expect(addon.predictChar('a')).toBe(false);
|
||||||
|
expect(addon.predictBackspace()).toBe(false);
|
||||||
|
addon.clearPredictions();
|
||||||
|
addon.reconcile();
|
||||||
|
addon.refreshFont();
|
||||||
|
expect(addon.hasPredictions).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hostile terminal stubs never propagate exceptions', () => {
|
||||||
|
const hostile = {
|
||||||
|
element: document.createElement('div'),
|
||||||
|
cols: 80,
|
||||||
|
rows: 24,
|
||||||
|
options: {},
|
||||||
|
buffer: {
|
||||||
|
active: {
|
||||||
|
viewportY: 0,
|
||||||
|
baseY: 0,
|
||||||
|
cursorX: 0,
|
||||||
|
cursorY: 0,
|
||||||
|
getLine: () => {
|
||||||
|
throw new Error('boom');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const a = new PredictiveEchoAddon();
|
||||||
|
expect(() => a.activate(hostile as never)).not.toThrow();
|
||||||
|
expect(a.predictChar('x')).toBe(false); // getLine throws inside -> caught
|
||||||
|
expect(() => a.reconcile()).not.toThrow();
|
||||||
|
a.dispose();
|
||||||
|
|
||||||
|
// Terminal with no render dimensions: addon inert, no throws
|
||||||
|
const dimless = composerMock();
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
delete (dimless.terminal as any)._core;
|
||||||
|
const b = new PredictiveEchoAddon();
|
||||||
|
b.activate(dimless.terminal as never);
|
||||||
|
expect(b.predictChar('x')).toBe(false);
|
||||||
|
b.dispose();
|
||||||
|
dimless.cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('underlinePredictions styles spans; refreshFont re-reads the rendered color', () => {
|
||||||
|
const themed = composerMock({ theme: { foreground: '#aabbcc', background: '#112233' } });
|
||||||
|
// The recipe prefers the computed .xterm-rows color (what xterm really
|
||||||
|
// renders with); give the mock rows an explicit color like a real skin.
|
||||||
|
const rows = themed.terminal.element.querySelector('.xterm-rows') as HTMLElement;
|
||||||
|
rows.style.color = 'rgb(170, 187, 204)';
|
||||||
|
const a = new PredictiveEchoAddon({ underlinePredictions: true });
|
||||||
|
a.activate(themed.terminal as never);
|
||||||
|
a.predictChar('u');
|
||||||
|
const span = themed.terminal.element.querySelector('.xterm-screen span') as HTMLSpanElement;
|
||||||
|
expect(span.style.textDecoration).toBe('underline');
|
||||||
|
expect(span.style.color).toBe('rgb(170, 187, 204)');
|
||||||
|
|
||||||
|
rows.style.color = 'rgb(255, 0, 0)'; // skin change
|
||||||
|
a.refreshFont();
|
||||||
|
a.clearPredictions();
|
||||||
|
a.reconcile(); // release the anchor hold armed by the clear
|
||||||
|
a.predictChar('v');
|
||||||
|
const span2 = themed.terminal.element.querySelector('.xterm-screen span') as HTMLSpanElement;
|
||||||
|
expect(span2.style.color).toBe('rgb(255, 0, 0)');
|
||||||
|
a.dispose();
|
||||||
|
themed.cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('anchor hold: backspace into echoed text suppresses prediction until a write parses', async () => {
|
||||||
|
// \x7f went to the wire with nothing outstanding: the cursor will move
|
||||||
|
// in a way the display has not shown, so anchoring now paints one cell
|
||||||
|
// off (review finding: "tehh" ghosts on backspace-then-retype at RTT)
|
||||||
|
expect(addon.predictBackspace()).toBe(false);
|
||||||
|
expect(addon.predictChar('x')).toBe(false);
|
||||||
|
expect(spansOf(mock)).toHaveLength(0);
|
||||||
|
mock.fireWriteParsed(); // the display caught up
|
||||||
|
await flushMicrotasks();
|
||||||
|
expect(addon.predictChar('x')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('anchor hold: clearPredictions suppresses until a write parses (or manual reconcile)', async () => {
|
||||||
|
addon.predictChar('a');
|
||||||
|
addon.clearPredictions(); // consumer saw Enter/Esc/arrow/paste
|
||||||
|
expect(addon.predictChar('b')).toBe(false);
|
||||||
|
mock.fireWriteParsed();
|
||||||
|
await flushMicrotasks();
|
||||||
|
expect(addon.predictChar('b')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('anchor hold: the inline predictChar reconcile does NOT release it', () => {
|
||||||
|
addon.clearPredictions();
|
||||||
|
// Several keystrokes in a row before any echo: all suppressed, because
|
||||||
|
// predictChar's inline pass must not count as the display catching up
|
||||||
|
expect(addon.predictChar('a')).toBe(false);
|
||||||
|
expect(addon.predictChar('b')).toBe(false);
|
||||||
|
addon.reconcile(); // public/manual pass IS the caught-up contract
|
||||||
|
expect(addon.predictChar('c')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('state getter reports outstanding/confirmedTotal/droppedTotal/anchor', async () => {
|
||||||
|
expect(addon.state).toEqual({ outstanding: 0, confirmedTotal: 0, droppedTotal: 0, anchor: null });
|
||||||
|
addon.predictChar('a');
|
||||||
|
addon.predictChar('b');
|
||||||
|
expect(addon.state.outstanding).toBe(2);
|
||||||
|
expect(addon.state.anchor).toEqual({ row: 0, col: 2 });
|
||||||
|
expect(addon.hasPredictions).toBe(true);
|
||||||
|
|
||||||
|
mock.setLine(0, '› a');
|
||||||
|
mock.setCursor(3, 0);
|
||||||
|
mock.fireWriteParsed();
|
||||||
|
await flushMicrotasks();
|
||||||
|
addon.clearPredictions();
|
||||||
|
expect(addon.state.confirmedTotal).toBe(1);
|
||||||
|
expect(addon.state.droppedTotal).toBe(1);
|
||||||
|
expect(addon.hasPredictions).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
/**
|
||||||
|
* @vitest-environment jsdom
|
||||||
|
*
|
||||||
|
* Layer 3: seeded property fuzz against the REAL xterm parser. Random
|
||||||
|
* interleavings of predictions, backspaces, clears, echo writes (correct,
|
||||||
|
* partial, foreign), screen clears, scrolls and cursor jumps; invariants
|
||||||
|
* checked after EVERY op:
|
||||||
|
* 1. span count === outstanding record count, every span inside the grid
|
||||||
|
* 2. no public method throws
|
||||||
|
* 3. eventual convergence: after the run settles (TTL elapse + reconcile),
|
||||||
|
* outstanding === 0 and the span container is empty
|
||||||
|
*
|
||||||
|
* Reproduce a failure with FUZZ_SEED=<seed> FUZZ_ITERS=<n> npx vitest run
|
||||||
|
* test/predictive-echo-fuzz.test.ts (the failing seed+iter is in the
|
||||||
|
* assertion message).
|
||||||
|
*/
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { PredictiveEchoAddon } from '../src/predictive-echo-addon.js';
|
||||||
|
import { CELL_H, CELL_W, createReplayTerminal } from './replay-helpers.js';
|
||||||
|
|
||||||
|
const SEED = Number(process.env.FUZZ_SEED ?? 1337);
|
||||||
|
const TOTAL_ITERS = Number(process.env.FUZZ_ITERS ?? 500);
|
||||||
|
const BATCHES = 4;
|
||||||
|
const TTL_MS = 5;
|
||||||
|
|
||||||
|
function mulberry32(seed: number) {
|
||||||
|
let a = seed >>> 0;
|
||||||
|
return () => {
|
||||||
|
a |= 0;
|
||||||
|
a = (a + 0x6d2b79f5) | 0;
|
||||||
|
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
||||||
|
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||||||
|
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const ALPHABET = [...'abcdefghij XZ!?', '你', '好', '😀'];
|
||||||
|
|
||||||
|
function sleep(ms: number) {
|
||||||
|
return new Promise((r) => setTimeout(r, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fuzzIteration(iter: number, label: string) {
|
||||||
|
const rand = mulberry32(SEED + iter);
|
||||||
|
const rt = createReplayTerminal(60, 12);
|
||||||
|
const addon = new PredictiveEchoAddon({ ttlMs: TTL_MS });
|
||||||
|
addon.activate(rt.hybrid);
|
||||||
|
const ctx = `${label} seed=${SEED} iter=${iter}`;
|
||||||
|
|
||||||
|
// Park the cursor mid-screen like a composer would
|
||||||
|
await rt.write('\x1b[6;3H');
|
||||||
|
|
||||||
|
const ops = 4 + Math.floor(rand() * 12);
|
||||||
|
for (let i = 0; i < ops; i++) {
|
||||||
|
const r = rand();
|
||||||
|
if (r < 0.35) {
|
||||||
|
addon.predictChar(ALPHABET[Math.floor(rand() * ALPHABET.length)]);
|
||||||
|
} else if (r < 0.43) {
|
||||||
|
addon.predictBackspace();
|
||||||
|
} else if (r < 0.48) {
|
||||||
|
addon.clearPredictions();
|
||||||
|
} else if (r < 0.62) {
|
||||||
|
// Correct-ish echo: write a run of random chars at the anchor and
|
||||||
|
// leave the cursor advanced (confirms whatever happens to match)
|
||||||
|
const a = addon.state.anchor;
|
||||||
|
if (a) {
|
||||||
|
const n = 1 + Math.floor(rand() * 3);
|
||||||
|
let text = '';
|
||||||
|
for (let k = 0; k < n; k++) text += ALPHABET[Math.floor(rand() * ALPHABET.length)];
|
||||||
|
await rt.write(`\x1b[${a.row + 1};${a.col + 1}H${text}`);
|
||||||
|
}
|
||||||
|
} else if (r < 0.72) {
|
||||||
|
// Foreign rewrite across the anchor row
|
||||||
|
await rt.write(`\x1b[6;1H${'Q'.repeat(1 + Math.floor(rand() * 20))}`);
|
||||||
|
} else if (r < 0.8) {
|
||||||
|
// Scroll: newlines at the bottom push history
|
||||||
|
await rt.write(`\x1b[12;1H${'\r\n'.repeat(1 + Math.floor(rand() * 3))}`);
|
||||||
|
} else if (r < 0.85) {
|
||||||
|
await rt.write('\x1b[2J\x1b[H'); // clear screen + home
|
||||||
|
} else if (r < 0.95) {
|
||||||
|
addon.reconcile();
|
||||||
|
} else {
|
||||||
|
// Cursor jump
|
||||||
|
const row = 1 + Math.floor(rand() * 12);
|
||||||
|
const col = 1 + Math.floor(rand() * 60);
|
||||||
|
await rt.write(`\x1b[${row};${col}H`);
|
||||||
|
}
|
||||||
|
await Promise.resolve(); // flush the debounced reconcile microtask
|
||||||
|
|
||||||
|
// Invariant 1: span/record parity + grid bounds, after every op
|
||||||
|
expect(rt.spanCount(), ctx).toBe(addon.state.outstanding);
|
||||||
|
for (const s of rt.spans()) {
|
||||||
|
const left = parseFloat(s.style.left);
|
||||||
|
const width = parseFloat(s.style.width);
|
||||||
|
const top = parseFloat(s.style.top);
|
||||||
|
expect(left + width, ctx).toBeLessThanOrEqual(60 * CELL_W);
|
||||||
|
expect(top, ctx).toBeLessThanOrEqual(11 * CELL_H);
|
||||||
|
expect(left, ctx).toBeGreaterThanOrEqual(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invariant 3: eventual convergence via echo/TTL, never via dispose
|
||||||
|
if (addon.state.outstanding > 0) {
|
||||||
|
await sleep(TTL_MS + 15);
|
||||||
|
addon.reconcile();
|
||||||
|
}
|
||||||
|
expect(addon.state.outstanding, ctx).toBe(0);
|
||||||
|
expect(rt.spanCount(), ctx).toBe(0);
|
||||||
|
|
||||||
|
addon.dispose();
|
||||||
|
rt.cleanup();
|
||||||
|
}
|
||||||
|
|
||||||
|
describe(`predictive echo fuzz (${TOTAL_ITERS} iterations, seed ${SEED})`, () => {
|
||||||
|
const perBatch = Math.ceil(TOTAL_ITERS / BATCHES);
|
||||||
|
for (let b = 0; b < BATCHES; b++) {
|
||||||
|
it(`batch ${b + 1}/${BATCHES}`, async () => {
|
||||||
|
const start = b * perBatch;
|
||||||
|
const end = Math.min(start + perBatch, TOTAL_ITERS);
|
||||||
|
for (let iter = start; iter < end; iter++) {
|
||||||
|
await fuzzIteration(iter, `batch${b + 1}`);
|
||||||
|
}
|
||||||
|
}, 60000);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -4,159 +4,155 @@ import { findPrompt, readTextAfterPrompt } from '../src/prompt-finder.js';
|
|||||||
import type { XtermTerminal, PromptFinder } from '../src/types.js';
|
import type { XtermTerminal, PromptFinder } from '../src/types.js';
|
||||||
|
|
||||||
function term(lines: string[]) {
|
function term(lines: string[]) {
|
||||||
return createMockTerminal({ buffer: { lines } });
|
return createMockTerminal({ buffer: { lines } });
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('findPrompt', () => {
|
describe('findPrompt', () => {
|
||||||
describe('character strategy', () => {
|
describe('character strategy', () => {
|
||||||
it('finds $ prompt at column 0', () => {
|
it('finds $ prompt at column 0', () => {
|
||||||
const { terminal, cleanup } = term(['output line', '$ ls -la']);
|
const { terminal, cleanup } = term(['output line', '$ ls -la']);
|
||||||
const finder: PromptFinder = { type: 'character', char: '$' };
|
const finder: PromptFinder = { type: 'character', char: '$' };
|
||||||
const pos = findPrompt(terminal as unknown as XtermTerminal, finder);
|
const pos = findPrompt(terminal as unknown as XtermTerminal, finder);
|
||||||
expect(pos).toEqual({ row: 1, col: 0 });
|
expect(pos).toEqual({ row: 1, col: 0 });
|
||||||
cleanup();
|
cleanup();
|
||||||
});
|
|
||||||
|
|
||||||
it('finds > prompt', () => {
|
|
||||||
const { terminal, cleanup } = term(['> hello']);
|
|
||||||
const finder: PromptFinder = { type: 'character', char: '>' };
|
|
||||||
const pos = findPrompt(terminal as unknown as XtermTerminal, finder);
|
|
||||||
expect(pos).toEqual({ row: 0, col: 0 });
|
|
||||||
cleanup();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('finds prompt with prefix (user@host)', () => {
|
|
||||||
const { terminal, cleanup } = term(['user@host:~$ command']);
|
|
||||||
const finder: PromptFinder = { type: 'character', char: '$' };
|
|
||||||
const pos = findPrompt(terminal as unknown as XtermTerminal, finder);
|
|
||||||
expect(pos).toEqual({ row: 0, col: 11 });
|
|
||||||
cleanup();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('scans bottom-up and returns lowest match', () => {
|
|
||||||
const { terminal, cleanup } = term([
|
|
||||||
'$ old prompt',
|
|
||||||
'output',
|
|
||||||
'$ current prompt',
|
|
||||||
]);
|
|
||||||
const finder: PromptFinder = { type: 'character', char: '$' };
|
|
||||||
const pos = findPrompt(terminal as unknown as XtermTerminal, finder);
|
|
||||||
expect(pos).toEqual({ row: 2, col: 0 });
|
|
||||||
cleanup();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns null when no prompt found', () => {
|
|
||||||
const { terminal, cleanup } = term(['no prompt here', 'or here']);
|
|
||||||
const finder: PromptFinder = { type: 'character', char: '$' };
|
|
||||||
const pos = findPrompt(terminal as unknown as XtermTerminal, finder);
|
|
||||||
expect(pos).toBeNull();
|
|
||||||
cleanup();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('finds Unicode prompt character', () => {
|
|
||||||
const { terminal, cleanup } = term(['\u276f hello']);
|
|
||||||
const finder: PromptFinder = { type: 'character', char: '\u276f' };
|
|
||||||
const pos = findPrompt(terminal as unknown as XtermTerminal, finder);
|
|
||||||
expect(pos).toEqual({ row: 0, col: 0 });
|
|
||||||
cleanup();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('regex strategy', () => {
|
it('finds > prompt', () => {
|
||||||
it('finds regex prompt', () => {
|
const { terminal, cleanup } = term(['> hello']);
|
||||||
const { terminal, cleanup } = term(['user@host:~/dir$ ls']);
|
const finder: PromptFinder = { type: 'character', char: '>' };
|
||||||
const finder: PromptFinder = { type: 'regex', pattern: /\$/ };
|
const pos = findPrompt(terminal as unknown as XtermTerminal, finder);
|
||||||
const pos = findPrompt(terminal as unknown as XtermTerminal, finder);
|
expect(pos).toEqual({ row: 0, col: 0 });
|
||||||
expect(pos).not.toBeNull();
|
cleanup();
|
||||||
expect(pos!.col).toBe(15);
|
|
||||||
cleanup();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('matches complex PS1 patterns', () => {
|
|
||||||
const { terminal, cleanup } = term(['(venv) user % cmd']);
|
|
||||||
const finder: PromptFinder = { type: 'regex', pattern: /%/ };
|
|
||||||
const pos = findPrompt(terminal as unknown as XtermTerminal, finder);
|
|
||||||
expect(pos).not.toBeNull();
|
|
||||||
expect(pos!.col).toBe(12);
|
|
||||||
cleanup();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns null on no match', () => {
|
|
||||||
const { terminal, cleanup } = term(['just output']);
|
|
||||||
const finder: PromptFinder = { type: 'regex', pattern: /\$\s*$/ };
|
|
||||||
const pos = findPrompt(terminal as unknown as XtermTerminal, finder);
|
|
||||||
expect(pos).toBeNull();
|
|
||||||
cleanup();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handles global flag safely (strips g to avoid lastIndex)', () => {
|
|
||||||
const { terminal, cleanup } = term(['user@host:~$ cmd']);
|
|
||||||
const finder: PromptFinder = { type: 'regex', pattern: /\$/g };
|
|
||||||
const pos = findPrompt(terminal as unknown as XtermTerminal, finder);
|
|
||||||
expect(pos).not.toBeNull();
|
|
||||||
expect(pos!.col).toBe(11);
|
|
||||||
// Call again — should return same result (no lastIndex drift)
|
|
||||||
const pos2 = findPrompt(terminal as unknown as XtermTerminal, finder);
|
|
||||||
expect(pos2).toEqual(pos);
|
|
||||||
cleanup();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('custom strategy', () => {
|
it('finds prompt with prefix (user@host)', () => {
|
||||||
it('uses custom finder function', () => {
|
const { terminal, cleanup } = term(['user@host:~$ command']);
|
||||||
const { terminal, cleanup } = term(['anything']);
|
const finder: PromptFinder = { type: 'character', char: '$' };
|
||||||
const finder: PromptFinder = {
|
const pos = findPrompt(terminal as unknown as XtermTerminal, finder);
|
||||||
type: 'custom',
|
expect(pos).toEqual({ row: 0, col: 11 });
|
||||||
find: () => ({ row: 5, col: 10 }),
|
cleanup();
|
||||||
};
|
|
||||||
const pos = findPrompt(terminal as unknown as XtermTerminal, finder);
|
|
||||||
expect(pos).toEqual({ row: 5, col: 10 });
|
|
||||||
cleanup();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handles null from custom finder', () => {
|
|
||||||
const { terminal, cleanup } = term(['anything']);
|
|
||||||
const finder: PromptFinder = {
|
|
||||||
type: 'custom',
|
|
||||||
find: () => null,
|
|
||||||
};
|
|
||||||
const pos = findPrompt(terminal as unknown as XtermTerminal, finder);
|
|
||||||
expect(pos).toBeNull();
|
|
||||||
cleanup();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('scans bottom-up and returns lowest match', () => {
|
||||||
|
const { terminal, cleanup } = term(['$ old prompt', 'output', '$ current prompt']);
|
||||||
|
const finder: PromptFinder = { type: 'character', char: '$' };
|
||||||
|
const pos = findPrompt(terminal as unknown as XtermTerminal, finder);
|
||||||
|
expect(pos).toEqual({ row: 2, col: 0 });
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when no prompt found', () => {
|
||||||
|
const { terminal, cleanup } = term(['no prompt here', 'or here']);
|
||||||
|
const finder: PromptFinder = { type: 'character', char: '$' };
|
||||||
|
const pos = findPrompt(terminal as unknown as XtermTerminal, finder);
|
||||||
|
expect(pos).toBeNull();
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('finds Unicode prompt character', () => {
|
||||||
|
const { terminal, cleanup } = term(['\u276f hello']);
|
||||||
|
const finder: PromptFinder = { type: 'character', char: '\u276f' };
|
||||||
|
const pos = findPrompt(terminal as unknown as XtermTerminal, finder);
|
||||||
|
expect(pos).toEqual({ row: 0, col: 0 });
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('regex strategy', () => {
|
||||||
|
it('finds regex prompt', () => {
|
||||||
|
const { terminal, cleanup } = term(['user@host:~/dir$ ls']);
|
||||||
|
const finder: PromptFinder = { type: 'regex', pattern: /\$/ };
|
||||||
|
const pos = findPrompt(terminal as unknown as XtermTerminal, finder);
|
||||||
|
expect(pos).not.toBeNull();
|
||||||
|
expect(pos!.col).toBe(15);
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches complex PS1 patterns', () => {
|
||||||
|
const { terminal, cleanup } = term(['(venv) user % cmd']);
|
||||||
|
const finder: PromptFinder = { type: 'regex', pattern: /%/ };
|
||||||
|
const pos = findPrompt(terminal as unknown as XtermTerminal, finder);
|
||||||
|
expect(pos).not.toBeNull();
|
||||||
|
expect(pos!.col).toBe(12);
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null on no match', () => {
|
||||||
|
const { terminal, cleanup } = term(['just output']);
|
||||||
|
const finder: PromptFinder = { type: 'regex', pattern: /\$\s*$/ };
|
||||||
|
const pos = findPrompt(terminal as unknown as XtermTerminal, finder);
|
||||||
|
expect(pos).toBeNull();
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles global flag safely (strips g to avoid lastIndex)', () => {
|
||||||
|
const { terminal, cleanup } = term(['user@host:~$ cmd']);
|
||||||
|
const finder: PromptFinder = { type: 'regex', pattern: /\$/g };
|
||||||
|
const pos = findPrompt(terminal as unknown as XtermTerminal, finder);
|
||||||
|
expect(pos).not.toBeNull();
|
||||||
|
expect(pos!.col).toBe(11);
|
||||||
|
// Call again — should return same result (no lastIndex drift)
|
||||||
|
const pos2 = findPrompt(terminal as unknown as XtermTerminal, finder);
|
||||||
|
expect(pos2).toEqual(pos);
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('custom strategy', () => {
|
||||||
|
it('uses custom finder function', () => {
|
||||||
|
const { terminal, cleanup } = term(['anything']);
|
||||||
|
const finder: PromptFinder = {
|
||||||
|
type: 'custom',
|
||||||
|
find: () => ({ row: 5, col: 10 }),
|
||||||
|
};
|
||||||
|
const pos = findPrompt(terminal as unknown as XtermTerminal, finder);
|
||||||
|
expect(pos).toEqual({ row: 5, col: 10 });
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles null from custom finder', () => {
|
||||||
|
const { terminal, cleanup } = term(['anything']);
|
||||||
|
const finder: PromptFinder = {
|
||||||
|
type: 'custom',
|
||||||
|
find: () => null,
|
||||||
|
};
|
||||||
|
const pos = findPrompt(terminal as unknown as XtermTerminal, finder);
|
||||||
|
expect(pos).toBeNull();
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('readTextAfterPrompt', () => {
|
describe('readTextAfterPrompt', () => {
|
||||||
it('reads text after prompt with offset', () => {
|
it('reads text after prompt with offset', () => {
|
||||||
const { terminal, cleanup } = term(['$ hello world']);
|
const { terminal, cleanup } = term(['$ hello world']);
|
||||||
const prompt = { row: 0, col: 0 };
|
const prompt = { row: 0, col: 0 };
|
||||||
const text = readTextAfterPrompt(terminal as unknown as XtermTerminal, prompt, 2);
|
const text = readTextAfterPrompt(terminal as unknown as XtermTerminal, prompt, 2);
|
||||||
expect(text).toBe('hello world');
|
expect(text).toBe('hello world');
|
||||||
cleanup();
|
cleanup();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns empty string for empty prompt line', () => {
|
it('returns empty string for empty prompt line', () => {
|
||||||
const { terminal, cleanup } = term(['$ ']);
|
const { terminal, cleanup } = term(['$ ']);
|
||||||
const prompt = { row: 0, col: 0 };
|
const prompt = { row: 0, col: 0 };
|
||||||
const text = readTextAfterPrompt(terminal as unknown as XtermTerminal, prompt, 2);
|
const text = readTextAfterPrompt(terminal as unknown as XtermTerminal, prompt, 2);
|
||||||
expect(text).toBe('');
|
expect(text).toBe('');
|
||||||
cleanup();
|
cleanup();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('trims trailing whitespace', () => {
|
it('trims trailing whitespace', () => {
|
||||||
const { terminal, cleanup } = term(['$ hello ']);
|
const { terminal, cleanup } = term(['$ hello ']);
|
||||||
const prompt = { row: 0, col: 0 };
|
const prompt = { row: 0, col: 0 };
|
||||||
const text = readTextAfterPrompt(terminal as unknown as XtermTerminal, prompt, 2);
|
const text = readTextAfterPrompt(terminal as unknown as XtermTerminal, prompt, 2);
|
||||||
expect(text).toBe('hello');
|
expect(text).toBe('hello');
|
||||||
cleanup();
|
cleanup();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles offset for complex prompts', () => {
|
it('handles offset for complex prompts', () => {
|
||||||
const { terminal, cleanup } = term(['user@host:~$ ls -la']);
|
const { terminal, cleanup } = term(['user@host:~$ ls -la']);
|
||||||
const prompt = { row: 0, col: 11 };
|
const prompt = { row: 0, col: 11 };
|
||||||
const text = readTextAfterPrompt(terminal as unknown as XtermTerminal, prompt, 2);
|
const text = readTextAfterPrompt(terminal as unknown as XtermTerminal, prompt, 2);
|
||||||
expect(text).toBe('ls -la');
|
expect(text).toBe('ls -la');
|
||||||
cleanup();
|
cleanup();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
/** Vite `?raw` imports used by replay-helpers.ts (fixture JSONL as strings). */
|
||||||
|
declare module '*.jsonl?raw' {
|
||||||
|
const content: string;
|
||||||
|
export default content;
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
/**
|
||||||
|
* Replay-test helpers: a structural hybrid terminal whose buffer, cursor and
|
||||||
|
* onWriteParsed delegate to a REAL @xterm/headless Terminal (so fixtures run
|
||||||
|
* through the real parser), while `element` is a jsdom div the addon can
|
||||||
|
* paint spans into. Works because XtermTerminal is structurally typed.
|
||||||
|
*
|
||||||
|
* Also carries the test-side mirror of Codeman's classifyPredictInput() and
|
||||||
|
* codex composer gate (the real ones live in terminal-ui.js and are pinned by
|
||||||
|
* the repo's Layer 4 vm tests; keep the two in sync).
|
||||||
|
*/
|
||||||
|
import { Terminal } from '@xterm/headless';
|
||||||
|
import type { XtermTerminal } from '../src/types.js';
|
||||||
|
// ?raw imports keep the jsdom environment free of node: builtins
|
||||||
|
import pasteBracketed from './fixtures/codex/paste-bracketed.jsonl?raw';
|
||||||
|
import slashPicker from './fixtures/codex/slash-picker.jsonl?raw';
|
||||||
|
import streamingBurst from './fixtures/codex/streaming-burst.jsonl?raw';
|
||||||
|
import streamingReal from './fixtures/codex/streaming-real.jsonl?raw';
|
||||||
|
import trustModal from './fixtures/codex/trust-modal.jsonl?raw';
|
||||||
|
import typeHello from './fixtures/codex/type-hello.jsonl?raw';
|
||||||
|
import wrap from './fixtures/codex/wrap.jsonl?raw';
|
||||||
|
|
||||||
|
const FIXTURES: Record<string, string> = {
|
||||||
|
'paste-bracketed': pasteBracketed,
|
||||||
|
'slash-picker': slashPicker,
|
||||||
|
'streaming-burst': streamingBurst,
|
||||||
|
'streaming-real': streamingReal,
|
||||||
|
'trust-modal': trustModal,
|
||||||
|
'type-hello': typeHello,
|
||||||
|
wrap,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CELL_W = 9;
|
||||||
|
export const CELL_H = 18;
|
||||||
|
|
||||||
|
export interface FixtureLine {
|
||||||
|
delayMs?: number;
|
||||||
|
keyAt?: boolean;
|
||||||
|
data: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FixtureMeta {
|
||||||
|
scenario: string;
|
||||||
|
cols: number;
|
||||||
|
rows: number;
|
||||||
|
codexVersion: string;
|
||||||
|
recordedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadFixture(name: string): { meta: FixtureMeta; lines: FixtureLine[] } {
|
||||||
|
const content = FIXTURES[name];
|
||||||
|
if (!content) throw new Error(`unknown fixture ${name}`);
|
||||||
|
const raw = content
|
||||||
|
.trim()
|
||||||
|
.split('\n')
|
||||||
|
.map((l) => JSON.parse(l));
|
||||||
|
return { meta: raw[0] as FixtureMeta, lines: raw.slice(1) as FixtureLine[] };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReplayTerminal {
|
||||||
|
hybrid: XtermTerminal;
|
||||||
|
term: Terminal;
|
||||||
|
write(data: string): Promise<void>;
|
||||||
|
cursorRowText(): string;
|
||||||
|
rowText(viewportRow: number): string;
|
||||||
|
spanCount(): number;
|
||||||
|
spans(): HTMLSpanElement[];
|
||||||
|
cleanup(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createReplayTerminal(cols: number, rows: number): ReplayTerminal {
|
||||||
|
const term = new Terminal({ cols, rows, scrollback: 2000, allowProposedApi: true });
|
||||||
|
|
||||||
|
const element = document.createElement('div');
|
||||||
|
element.className = 'terminal xterm';
|
||||||
|
const screen = document.createElement('div');
|
||||||
|
screen.className = 'xterm-screen';
|
||||||
|
const rowsEl = document.createElement('div');
|
||||||
|
rowsEl.className = 'xterm-rows';
|
||||||
|
element.appendChild(screen);
|
||||||
|
screen.appendChild(rowsEl);
|
||||||
|
document.body.appendChild(element);
|
||||||
|
|
||||||
|
const hybrid = {
|
||||||
|
element,
|
||||||
|
get cols() {
|
||||||
|
return term.cols;
|
||||||
|
},
|
||||||
|
get rows() {
|
||||||
|
return term.rows;
|
||||||
|
},
|
||||||
|
options: { fontFamily: 'monospace', fontSize: 14, fontWeight: 'normal', theme: {} },
|
||||||
|
buffer: {
|
||||||
|
active: {
|
||||||
|
get viewportY() {
|
||||||
|
return term.buffer.active.viewportY;
|
||||||
|
},
|
||||||
|
get baseY() {
|
||||||
|
return term.buffer.active.baseY;
|
||||||
|
},
|
||||||
|
get cursorX() {
|
||||||
|
return term.buffer.active.cursorX;
|
||||||
|
},
|
||||||
|
get cursorY() {
|
||||||
|
return term.buffer.active.cursorY;
|
||||||
|
},
|
||||||
|
getLine: (y: number) => term.buffer.active.getLine(y),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
onWriteParsed: (cb: () => void) => term.onWriteParsed(cb),
|
||||||
|
onResize: (cb: (s: { cols: number; rows: number }) => void) => term.onResize(cb),
|
||||||
|
_core: {
|
||||||
|
_renderService: {
|
||||||
|
dimensions: {
|
||||||
|
css: { cell: { width: CELL_W, height: CELL_H } },
|
||||||
|
device: { char: { top: 0, height: CELL_H } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
hybrid: hybrid as unknown as XtermTerminal,
|
||||||
|
term,
|
||||||
|
write: (data: string) => new Promise<void>((resolve) => term.write(data, () => resolve())),
|
||||||
|
cursorRowText() {
|
||||||
|
const b = term.buffer.active;
|
||||||
|
return b.getLine(b.baseY + b.cursorY)?.translateToString(true) ?? '';
|
||||||
|
},
|
||||||
|
rowText(viewportRow: number) {
|
||||||
|
const b = term.buffer.active;
|
||||||
|
return b.getLine(b.baseY + viewportRow)?.translateToString(true) ?? '';
|
||||||
|
},
|
||||||
|
spanCount() {
|
||||||
|
return element.querySelectorAll('[data-predictive-echo] span').length;
|
||||||
|
},
|
||||||
|
spans() {
|
||||||
|
return Array.from(element.querySelectorAll('[data-predictive-echo] span')) as HTMLSpanElement[];
|
||||||
|
},
|
||||||
|
cleanup() {
|
||||||
|
term.dispose();
|
||||||
|
element.remove();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Codeman-side mirrors (keep in sync with terminal-ui.js) ────────────
|
||||||
|
|
||||||
|
/** Mirror of window.CodemanTerminalInput.classifyPredictInput. */
|
||||||
|
export function classifyPredictInput(data: string): 'char' | 'backspace' | 'clear' | 'text' {
|
||||||
|
const cps = Array.from(data);
|
||||||
|
if (cps.length === 1) {
|
||||||
|
const cp = cps[0].codePointAt(0)!;
|
||||||
|
if (cp === 0x7f) return 'backspace';
|
||||||
|
if (cp >= 0x20) return 'char';
|
||||||
|
return 'clear';
|
||||||
|
}
|
||||||
|
if (data.charCodeAt(0) === 0x1b) return 'clear';
|
||||||
|
if (data.charCodeAt(0) >= 0x20) return 'text';
|
||||||
|
return 'clear';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mirror of the codex composer-row gate (CODEX_COMPOSER_ROW_RE). */
|
||||||
|
export const CODEX_COMPOSER_ROW_RE = /^› /;
|
||||||
|
|
||||||
|
export function codexComposerGate(terminal: XtermTerminal): boolean {
|
||||||
|
try {
|
||||||
|
const buf = terminal.buffer.active;
|
||||||
|
const line = buf.getLine(buf.baseY + (buf.cursorY ?? 0));
|
||||||
|
return !!line && CODEX_COMPOSER_ROW_RE.test(line.translateToString(true));
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,6 +26,13 @@ export default defineConfig([
|
|||||||
' this.activate(terminal);',
|
' this.activate(terminal);',
|
||||||
' }',
|
' }',
|
||||||
' };',
|
' };',
|
||||||
|
' window.PredictiveEchoAddon=XtermZerolagInput.PredictiveEchoAddon;',
|
||||||
|
' window.PredictiveEchoOverlay=class extends XtermZerolagInput.PredictiveEchoAddon{',
|
||||||
|
' constructor(terminal){',
|
||||||
|
' super({});',
|
||||||
|
' this.activate(terminal);',
|
||||||
|
' }',
|
||||||
|
' };',
|
||||||
'}',
|
'}',
|
||||||
].join('\n'),
|
].join('\n'),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -65,6 +65,22 @@ appendFileSync(
|
|||||||
'}\n'
|
'}\n'
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Predictive echo (codex): separate bundle so the zerolag bundle stays byte-identical
|
||||||
|
run('xterm-predictive-echo', 'npx esbuild packages/xterm-zerolag-input/src/predictive-echo-addon.ts --bundle --minify --format=iife --global-name=XtermPredictiveEcho --outfile=dist/web/public/vendor/xterm-predictive-echo.js');
|
||||||
|
appendFileSync(
|
||||||
|
join(ROOT, 'dist/web/public/vendor/xterm-predictive-echo.js'),
|
||||||
|
'\n// Global aliases for browser usage\n' +
|
||||||
|
'if(typeof window!=="undefined"){' +
|
||||||
|
'window.PredictiveEchoAddon=XtermPredictiveEcho.PredictiveEchoAddon;' +
|
||||||
|
'window.PredictiveEchoOverlay=class extends XtermPredictiveEcho.PredictiveEchoAddon{' +
|
||||||
|
'constructor(terminal){' +
|
||||||
|
'super({});' +
|
||||||
|
'this.activate(terminal);' +
|
||||||
|
'}' +
|
||||||
|
'};' +
|
||||||
|
'}\n'
|
||||||
|
);
|
||||||
|
|
||||||
// 4. Minify frontend assets
|
// 4. Minify frontend assets
|
||||||
run('minify input-cjk.js', 'npx esbuild dist/web/public/input-cjk.js --minify --outfile=dist/web/public/input-cjk.js --allow-overwrite');
|
run('minify input-cjk.js', 'npx esbuild dist/web/public/input-cjk.js --minify --outfile=dist/web/public/input-cjk.js --allow-overwrite');
|
||||||
run('minify i18n.js', 'npx esbuild dist/web/public/i18n.js --minify --outfile=dist/web/public/i18n.js --allow-overwrite');
|
run('minify i18n.js', 'npx esbuild dist/web/public/i18n.js --minify --outfile=dist/web/public/i18n.js --allow-overwrite');
|
||||||
@@ -106,6 +122,7 @@ console.log('\n[build] content-hash cache busting');
|
|||||||
'subagent-windows.js',
|
'subagent-windows.js',
|
||||||
'image-input.js',
|
'image-input.js',
|
||||||
'vendor/xterm-zerolag-input.js',
|
'vendor/xterm-zerolag-input.js',
|
||||||
|
'vendor/xterm-predictive-echo.js',
|
||||||
];
|
];
|
||||||
const manifest = {};
|
const manifest = {};
|
||||||
for (const file of HASHABLE) {
|
for (const file of HASHABLE) {
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* @fileoverview Replays a codex fixture (recorded by record-codex-frames.mjs)
|
||||||
|
* through @xterm/headless and prints the measurements the predictive-echo
|
||||||
|
* design doc records: cursor position + composer-row text at every keystroke
|
||||||
|
* injection point, and the final screen with cursor + baseY state.
|
||||||
|
*
|
||||||
|
* Usage: node scripts/dev/analyze-codex-frames.mjs <fixture.jsonl>
|
||||||
|
*/
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { createRequire } from 'node:module';
|
||||||
|
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
const { Terminal } = require('@xterm/headless');
|
||||||
|
|
||||||
|
const file = process.argv[2];
|
||||||
|
if (!file) throw new Error('usage: analyze-codex-frames.mjs <fixture.jsonl>');
|
||||||
|
const lines = readFileSync(file, 'utf8').trim().split('\n').map(JSON.parse);
|
||||||
|
const meta = lines.shift();
|
||||||
|
console.log('meta:', JSON.stringify(meta));
|
||||||
|
|
||||||
|
const term = new Terminal({ cols: meta.cols, rows: meta.rows, scrollback: 1000, allowProposedApi: true });
|
||||||
|
const write = (data) => new Promise((r) => term.write(data, r));
|
||||||
|
|
||||||
|
const snap = () => {
|
||||||
|
const buf = term.buffer.active;
|
||||||
|
const row = buf.getLine(buf.baseY + buf.cursorY);
|
||||||
|
return {
|
||||||
|
cursorX: buf.cursorX,
|
||||||
|
cursorY: buf.cursorY,
|
||||||
|
baseY: buf.baseY,
|
||||||
|
rowText: row ? row.translateToString(true) : null,
|
||||||
|
cursorCell: row?.getCell?.(buf.cursorX)?.getChars() ?? null,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.keyAt) {
|
||||||
|
const s = snap();
|
||||||
|
console.log(`KEY ${JSON.stringify(line.data)} @ cursor(${s.cursorX},${s.cursorY}) baseY=${s.baseY}`);
|
||||||
|
console.log(` row: ${JSON.stringify(s.rowText)}`);
|
||||||
|
console.log(` cell-at-cursor: ${JSON.stringify(s.cursorCell)}`);
|
||||||
|
} else {
|
||||||
|
await write(line.data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const final = snap();
|
||||||
|
console.log('\nFINAL screen (| marks cursor row/col):');
|
||||||
|
const buf = term.buffer.active;
|
||||||
|
for (let y = 0; y < meta.rows; y++) {
|
||||||
|
const line = buf.getLine(buf.baseY + y);
|
||||||
|
let text = line ? line.translateToString(true) : '';
|
||||||
|
if (y === final.cursorY) text = text.slice(0, final.cursorX) + '|' + text.slice(final.cursorX);
|
||||||
|
if (text.trim()) console.log(String(y).padStart(3), JSON.stringify(text));
|
||||||
|
}
|
||||||
|
console.log('cursor:', JSON.stringify(final), 'viewportY:', buf.viewportY);
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* @fileoverview Records real codex TUI output into JSONL fixtures for the
|
||||||
|
* predictive-echo replay tests (packages/xterm-zerolag-input/test/codex-replay.test.ts).
|
||||||
|
*
|
||||||
|
* The pipeline reproduces production byte-for-byte: codex runs inside tmux
|
||||||
|
* (status off, like tmux-manager.ts sessions) driven through a node-pty client,
|
||||||
|
* and every chunk passes through the SAME full strip session.ts applies to
|
||||||
|
* codex-mode output (alt-screen toggles, \x1b[3J, mouse DECSETs, with the
|
||||||
|
* split-sequence carry). What lands in the fixture is what xterm.js receives.
|
||||||
|
*
|
||||||
|
* Fixture format: line 1 is a meta object {scenario, cols, rows, codexVersion,
|
||||||
|
* recordedAt}; every following line is {delayMs, data} where delayMs is the gap
|
||||||
|
* since the previous chunk and data is the stripped chunk. Keystroke injection
|
||||||
|
* points are recorded as {keyAt: true, data} lines so the replay knows where
|
||||||
|
* predictChar() calls belong.
|
||||||
|
*
|
||||||
|
* Usage: node scripts/dev/record-codex-frames.mjs <scenario|all> [--out <dir>]
|
||||||
|
* Scenarios: type-hello, slash-picker, wrap, streaming-burst, paste-bracketed
|
||||||
|
*
|
||||||
|
* The CODEX_HOME is a throwaway temp dir with a fake auth.json; the fake key is
|
||||||
|
* asserted absent from every recorded byte before the fixture is written.
|
||||||
|
*/
|
||||||
|
import pty from 'node-pty';
|
||||||
|
import { execSync } from 'node:child_process';
|
||||||
|
import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from 'node:fs';
|
||||||
|
import { join, dirname } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
||||||
|
const FAKE_KEY = 'sk-test-123';
|
||||||
|
const COLS = 100;
|
||||||
|
const ROWS = 30;
|
||||||
|
const BOOT_WAIT_MS = 4500;
|
||||||
|
// NOT under /tmp: codex prints a "Refusing to create helper binaries under
|
||||||
|
// temporary dir" warning that embeds the CODEX_HOME path when it lives in /tmp.
|
||||||
|
// The repo's gitignored tmp/ avoids both the warning and the path leak.
|
||||||
|
const SCRATCH = join(ROOT, 'tmp');
|
||||||
|
|
||||||
|
// Mirror of the codex-mode FULL strip in session.ts _handleTerminalOutput().
|
||||||
|
function makeStripper() {
|
||||||
|
let carry = '';
|
||||||
|
return (data) => {
|
||||||
|
data = carry + data;
|
||||||
|
carry = '';
|
||||||
|
const splitTail = data.match(/\x1b(?:\[\??[0-9]{0,4})?$/);
|
||||||
|
if (splitTail) {
|
||||||
|
carry = splitTail[0];
|
||||||
|
data = data.slice(0, -splitTail[0].length);
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
.replace(/\x1b\[\?(?:47|1047|1049)[hl]/g, '')
|
||||||
|
.replace(/\x1b\[3J/g, '')
|
||||||
|
.replace(/\x1b\[\?(?:1000|1001|1002|1003|1005|1006|1007)[hl]/g, '');
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Each step: wait `waitMs` after the previous step, then write `keys` to the pty.
|
||||||
|
const SCENARIOS = {
|
||||||
|
'type-hello': [
|
||||||
|
...'hello'.split('').map((ch, i) => ({ waitMs: i === 0 ? BOOT_WAIT_MS : 90, keys: ch })),
|
||||||
|
{ waitMs: 1500, keys: '' },
|
||||||
|
],
|
||||||
|
'slash-picker': [
|
||||||
|
{ waitMs: BOOT_WAIT_MS, keys: '/' },
|
||||||
|
{ waitMs: 400, keys: 'm' },
|
||||||
|
{ waitMs: 150, keys: 'o' },
|
||||||
|
{ waitMs: 1200, keys: '\x1b' },
|
||||||
|
{ waitMs: 500, keys: '' },
|
||||||
|
],
|
||||||
|
wrap: [
|
||||||
|
{ waitMs: BOOT_WAIT_MS, keys: '' },
|
||||||
|
...'the quick brown fox jumps over the lazy dog and keeps running until the composer box has to wrap this line twice over'
|
||||||
|
.split('')
|
||||||
|
.map((ch) => ({ waitMs: 25, keys: ch })),
|
||||||
|
{ waitMs: 1500, keys: '' },
|
||||||
|
],
|
||||||
|
'streaming-burst': [
|
||||||
|
...'hello'.split('').map((ch, i) => ({ waitMs: i === 0 ? BOOT_WAIT_MS : 40, keys: ch })),
|
||||||
|
{ waitMs: 300, keys: '\r' },
|
||||||
|
{ waitMs: 5000, keys: '' },
|
||||||
|
],
|
||||||
|
'paste-bracketed': [
|
||||||
|
{ waitMs: BOOT_WAIT_MS, keys: 'a' },
|
||||||
|
{ waitMs: 90, keys: 'b' },
|
||||||
|
{ waitMs: 400, keys: '\x1b[200~XYZpasted\x1b[201~' },
|
||||||
|
{ waitMs: 1500, keys: '' },
|
||||||
|
],
|
||||||
|
// REAL-AUTH streaming (CODEX_RECORD_REAL=1 only): a genuine model response
|
||||||
|
// streaming above the pinned composer while keystrokes land mid-stream.
|
||||||
|
// This is the one shape the fake-key lab can never produce: real output
|
||||||
|
// pushes lines to history (baseY grows), exercising the no-drop-on-baseY
|
||||||
|
// rule against reality. Uses the user's real ~/.codex; the fixture is
|
||||||
|
// secret-scanned (sk- / JWT prefixes) before it is written.
|
||||||
|
'streaming-real': {
|
||||||
|
realAuth: true,
|
||||||
|
steps: [
|
||||||
|
{ waitMs: BOOT_WAIT_MS, keys: '\r' }, // trust dialog (untrusted workdir)
|
||||||
|
{ waitMs: 2500, keys: '' },
|
||||||
|
...'reply with the single word hello'.split('').map((ch) => ({ waitMs: 15, keys: ch })),
|
||||||
|
{ waitMs: 400, keys: '\r' },
|
||||||
|
{ waitMs: 4000, keys: 'a' }, // typed MID-STREAM
|
||||||
|
{ waitMs: 120, keys: 'b' },
|
||||||
|
{ waitMs: 120, keys: 'c' },
|
||||||
|
{ waitMs: 14000, keys: '' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
// First-run trust dialog: the modal surface where typed chars must NOT be
|
||||||
|
// predicted (the predictWhen ghost eliminator). Recorded UNTRUSTED so the
|
||||||
|
// dialog actually appears; 'x' exercises typing at a non-composer cursor.
|
||||||
|
'trust-modal': {
|
||||||
|
trusted: false,
|
||||||
|
steps: [
|
||||||
|
{ waitMs: BOOT_WAIT_MS, keys: 'x' },
|
||||||
|
{ waitMs: 800, keys: '\r' },
|
||||||
|
{ waitMs: 2500, keys: '' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
async function record(scenario, outDir) {
|
||||||
|
const spec = SCENARIOS[scenario];
|
||||||
|
if (!spec) throw new Error(`unknown scenario ${scenario}`);
|
||||||
|
const steps = Array.isArray(spec) ? spec : spec.steps;
|
||||||
|
const trusted = Array.isArray(spec) ? true : (spec.trusted ?? true);
|
||||||
|
const realAuth = Array.isArray(spec) ? false : (spec.realAuth ?? false);
|
||||||
|
if (realAuth && process.env.CODEX_RECORD_REAL !== '1') {
|
||||||
|
console.log(`${scenario}: SKIPPED (needs CODEX_RECORD_REAL=1 and a real ~/.codex login)`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
mkdirSync(SCRATCH, { recursive: true });
|
||||||
|
const lab = mkdtempSync(join(SCRATCH, 'codexrec-'));
|
||||||
|
const workdir = mkdtempSync(join(SCRATCH, 'codexrec-work-'));
|
||||||
|
if (!realAuth) {
|
||||||
|
writeFileSync(join(lab, 'auth.json'), JSON.stringify({ OPENAI_API_KEY: FAKE_KEY }));
|
||||||
|
if (trusted) {
|
||||||
|
// Pre-trust the workdir so boot goes straight to the composer instead of
|
||||||
|
// the first-run trust dialog (which trust-modal records deliberately).
|
||||||
|
writeFileSync(join(lab, 'config.toml'), `[projects."${workdir}"]\ntrust_level = "trusted"\n`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const sock = `codexrec-${process.pid}`;
|
||||||
|
const codexVersion = execSync('codex --version', { encoding: 'utf8' }).trim();
|
||||||
|
|
||||||
|
const lines = [];
|
||||||
|
const strip = makeStripper();
|
||||||
|
let lastChunkAt = null;
|
||||||
|
let recording = true;
|
||||||
|
const proc = pty.spawn(
|
||||||
|
'tmux',
|
||||||
|
['-L', sock, '-f', '/dev/null', 'new-session', '-s', 'rec', ';', 'set', '-t', 'rec', 'status', 'off'],
|
||||||
|
{
|
||||||
|
name: 'xterm-256color',
|
||||||
|
cols: COLS,
|
||||||
|
rows: ROWS,
|
||||||
|
cwd: workdir,
|
||||||
|
env: realAuth ? { ...process.env, SHELL: '/bin/bash' } : { ...process.env, CODEX_HOME: lab, SHELL: '/bin/bash' },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
proc.onData((data) => {
|
||||||
|
if (!recording) return; // teardown frames ([server exited]) stay out
|
||||||
|
const now = performance.now();
|
||||||
|
const stripped = strip(data);
|
||||||
|
if (!stripped) return; // timing folds into the next chunk's delay
|
||||||
|
lines.push({ delayMs: lastChunkAt === null ? 0 : Math.round(now - lastChunkAt), data: stripped });
|
||||||
|
lastChunkAt = now;
|
||||||
|
});
|
||||||
|
|
||||||
|
// tmux session starts with a shell; launch codex in it so the strip pipeline
|
||||||
|
// sees the same attach-then-launch order production uses.
|
||||||
|
await sleep(700);
|
||||||
|
proc.write(`exec codex\r`);
|
||||||
|
|
||||||
|
for (const step of steps) {
|
||||||
|
await sleep(step.waitMs);
|
||||||
|
if (step.keys) {
|
||||||
|
lines.push({ keyAt: true, data: step.keys });
|
||||||
|
proc.write(step.keys);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
recording = false;
|
||||||
|
try {
|
||||||
|
execSync(`tmux -L ${sock} kill-server`, { stdio: 'ignore' });
|
||||||
|
} catch {
|
||||||
|
/* already gone */
|
||||||
|
}
|
||||||
|
proc.kill();
|
||||||
|
await sleep(200);
|
||||||
|
|
||||||
|
const allBytes = lines.map((l) => l.data).join('');
|
||||||
|
if (allBytes.includes(FAKE_KEY)) throw new Error(`fixture ${scenario} leaked the fake key; NOT writing`);
|
||||||
|
if (allBytes.includes(lab)) throw new Error(`fixture ${scenario} leaked the lab path; NOT writing`);
|
||||||
|
if (realAuth && /sk-[A-Za-z0-9_-]{8}|eyJ[A-Za-z0-9_-]{20}/.test(allBytes))
|
||||||
|
throw new Error(`fixture ${scenario} may contain credential material; NOT writing`);
|
||||||
|
|
||||||
|
mkdirSync(outDir, { recursive: true });
|
||||||
|
const meta = { scenario, cols: COLS, rows: ROWS, codexVersion, recordedAt: new Date().toISOString() };
|
||||||
|
const out = join(outDir, `${scenario}.jsonl`);
|
||||||
|
writeFileSync(out, [JSON.stringify(meta), ...lines.map((l) => JSON.stringify(l))].join('\n') + '\n');
|
||||||
|
rmSync(lab, { recursive: true, force: true });
|
||||||
|
rmSync(workdir, { recursive: true, force: true });
|
||||||
|
console.log(`${scenario}: ${lines.length} lines -> ${out}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sleep(ms) {
|
||||||
|
return new Promise((r) => setTimeout(r, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
const arg = process.argv[2];
|
||||||
|
const outIdx = process.argv.indexOf('--out');
|
||||||
|
const outDir =
|
||||||
|
outIdx !== -1 ? process.argv[outIdx + 1] : join(ROOT, 'packages', 'xterm-zerolag-input', 'test', 'fixtures', 'codex');
|
||||||
|
const wanted = arg === 'all' || !arg ? Object.keys(SCENARIOS) : [arg];
|
||||||
|
for (const s of wanted) {
|
||||||
|
await record(s, outDir);
|
||||||
|
}
|
||||||
@@ -304,6 +304,35 @@ if (isGlobalInstall) {
|
|||||||
} catch {
|
} catch {
|
||||||
console.log(colors.yellow('⚠ Failed to bundle xterm-zerolag-input — overlay may not work in dev mode'));
|
console.log(colors.yellow('⚠ Failed to bundle xterm-zerolag-input — overlay may not work in dev mode'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Predictive echo (codex): SEPARATE bundle so the zerolag bundle above stays
|
||||||
|
// byte-identical. If this file is missing or broken, codex simply falls back
|
||||||
|
// to plain PTY echo (pre-predictive behavior); nothing else is affected.
|
||||||
|
try {
|
||||||
|
const predSrc = join(import.meta.dirname, '..', 'packages', 'xterm-zerolag-input', 'src', 'predictive-echo-addon.ts');
|
||||||
|
const predOut = join(vendorDir, 'xterm-predictive-echo.js');
|
||||||
|
execSync(
|
||||||
|
`npx esbuild "${predSrc}" --bundle --format=iife --global-name=XtermPredictiveEcho --outfile="${predOut}"`,
|
||||||
|
{ stdio: 'pipe' }
|
||||||
|
);
|
||||||
|
const { appendFileSync } = await import('fs');
|
||||||
|
appendFileSync(
|
||||||
|
predOut,
|
||||||
|
'\n// Global aliases for browser usage\n' +
|
||||||
|
'if(typeof window!=="undefined"){' +
|
||||||
|
'window.PredictiveEchoAddon=XtermPredictiveEcho.PredictiveEchoAddon;' +
|
||||||
|
'window.PredictiveEchoOverlay=class extends XtermPredictiveEcho.PredictiveEchoAddon{' +
|
||||||
|
'constructor(terminal){' +
|
||||||
|
'super({});' +
|
||||||
|
'this.activate(terminal);' +
|
||||||
|
'}' +
|
||||||
|
'};' +
|
||||||
|
'}\n'
|
||||||
|
);
|
||||||
|
console.log(colors.green('✓ xterm-predictive-echo bundled to vendor/'));
|
||||||
|
} catch (e) {
|
||||||
|
console.log(colors.yellow('⚠ predictive-echo bundle failed (codex uses plain echo): ' + e.message));
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
hasWarnings = true;
|
hasWarnings = true;
|
||||||
console.log(colors.yellow('⚠ Failed to copy xterm vendor files'));
|
console.log(colors.yellow('⚠ Failed to copy xterm vendor files'));
|
||||||
|
|||||||
@@ -699,6 +699,10 @@ class CodemanApp {
|
|||||||
// (not at buffer.cursorY, which reflects Ink's internal cursor position)
|
// (not at buffer.cursorY, which reflects Ink's internal cursor position)
|
||||||
this._localEchoOverlay = null; // created after terminal.open()
|
this._localEchoOverlay = null; // created after terminal.open()
|
||||||
this._localEchoEnabled = false; // true when setting on + session active
|
this._localEchoEnabled = false; // true when setting on + session active
|
||||||
|
// Predictive write-through echo (codex) — created after terminal.open()
|
||||||
|
// from the separate vendor/xterm-predictive-echo.js bundle (may stay null)
|
||||||
|
this._predictiveEcho = null;
|
||||||
|
this._localEchoPolicy = 'off'; // 'buffer' | 'predict' | 'off' (per active session)
|
||||||
this._restoringFlushedState = false; // true during selectSession buffer load — protects flushed Maps
|
this._restoringFlushedState = false; // true during selectSession buffer load — protects flushed Maps
|
||||||
|
|
||||||
// Accessibility: Focus trap for modals
|
// Accessibility: Focus trap for modals
|
||||||
@@ -3021,6 +3025,9 @@ class CodemanApp {
|
|||||||
// terminal buffer reloads and prompt is visible again. _render() re-scans
|
// terminal buffer reloads and prompt is visible again. _render() re-scans
|
||||||
// for the ❯ prompt on every call, so rerender() after buffer load repositions it.
|
// for the ❯ prompt on every call, so rerender() after buffer load repositions it.
|
||||||
this._localEchoOverlay?.rerender();
|
this._localEchoOverlay?.rerender();
|
||||||
|
// Deliberate asymmetry: buffer-mode pending text SURVIVES reconnect (not
|
||||||
|
// yet sent); predictions do not (their keystrokes were already delivered).
|
||||||
|
this._predictiveEcho?.clearPredictions();
|
||||||
// Clear pending hooks
|
// Clear pending hooks
|
||||||
this.pendingHooks.clear();
|
this.pendingHooks.clear();
|
||||||
// Clear parent name cache (prevents stale session name entries accumulating)
|
// Clear parent name cache (prevents stale session name entries accumulating)
|
||||||
@@ -4127,6 +4134,9 @@ class CodemanApp {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
this._localEchoOverlay?.clear();
|
this._localEchoOverlay?.clear();
|
||||||
|
// Predictions are ephemeral + already sent: nothing to save/restore
|
||||||
|
// across a tab switch (unlike the buffer overlay's setFlushed machinery)
|
||||||
|
this._predictiveEcho?.clearPredictions();
|
||||||
// Prevent _detectBufferText() from picking up Claude's Ink UI text
|
// Prevent _detectBufferText() from picking up Claude's Ink UI text
|
||||||
// (status bar, model info, etc.) as "user input" on fresh sessions.
|
// (status bar, model info, etc.) as "user input" on fresh sessions.
|
||||||
// Only sessions with prior flushed text (from tab-switch-away) need detection.
|
// Only sessions with prior flushed text (from tab-switch-away) need detection.
|
||||||
|
|||||||
@@ -38,6 +38,7 @@
|
|||||||
<!-- WebGL addon lazy-loaded by app.js on desktop only (skipped on mobile, saving 244KB) -->
|
<!-- WebGL addon lazy-loaded by app.js on desktop only (skipped on mobile, saving 244KB) -->
|
||||||
<script defer src="vendor/xterm-addon-unicode11.min.js"></script>
|
<script defer src="vendor/xterm-addon-unicode11.min.js"></script>
|
||||||
<script defer src="vendor/xterm-zerolag-input.js"></script>
|
<script defer src="vendor/xterm-zerolag-input.js"></script>
|
||||||
|
<script defer src="vendor/xterm-predictive-echo.js"></script>
|
||||||
<script defer src="vendor/marked.min.js"></script>
|
<script defer src="vendor/marked.min.js"></script>
|
||||||
<!-- DOMPurify (allowlist HTML sanitizer for rendered markdown).
|
<!-- DOMPurify (allowlist HTML sanitizer for rendered markdown).
|
||||||
Must load before sanitize-html.js (which wires it) and app.js (which calls it). -->
|
Must load before sanitize-html.js (which wires it) and app.js (which calls it). -->
|
||||||
|
|||||||
@@ -601,6 +601,9 @@ const KeyboardAccessoryBar = {
|
|||||||
* must be written raw to be interpreted as key presses by Ink. */
|
* must be written raw to be interpreted as key presses by Ink. */
|
||||||
sendKey(escapeSequence) {
|
sendKey(escapeSequence) {
|
||||||
if (!app.activeSessionId) return;
|
if (!app.activeSessionId) return;
|
||||||
|
// Arrows/Esc move the server-side cursor and bypass onData: clear
|
||||||
|
// predictions now instead of waiting out the ~150ms off-row grace.
|
||||||
|
app._predictiveEcho?.clearPredictions();
|
||||||
fetch(`/api/sessions/${app.activeSessionId}/input`, {
|
fetch(`/api/sessions/${app.activeSessionId}/input`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ const APP_SHELL = [
|
|||||||
'/vendor/xterm-addon-fit.min.js',
|
'/vendor/xterm-addon-fit.min.js',
|
||||||
'/vendor/xterm-addon-unicode11.min.js',
|
'/vendor/xterm-addon-unicode11.min.js',
|
||||||
'/vendor/xterm-zerolag-input.js',
|
'/vendor/xterm-zerolag-input.js',
|
||||||
|
'/vendor/xterm-predictive-echo.js',
|
||||||
'/vendor/xterm.css',
|
'/vendor/xterm.css',
|
||||||
'/icon-192.png',
|
'/icon-192.png',
|
||||||
'/icon-512.png',
|
'/icon-512.png',
|
||||||
|
|||||||
@@ -62,6 +62,42 @@
|
|||||||
return COMPOSER_NAV_KEY_PATTERN.test(data);
|
return COMPOSER_NAV_KEY_PATTERN.test(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Codex composer-row signature, measured against codex-cli 0.147.0
|
||||||
|
// (docs/predictive-echo-plan.md): the composer's cursor row starts with
|
||||||
|
// "› " (U+203A + space) when empty (placeholder text), while typing, and
|
||||||
|
// while the slash picker filters. Modal rows ("Press enter to continue")
|
||||||
|
// and wrapped continuation rows (2-space indent) do NOT match — that is
|
||||||
|
// the ghost eliminator: no prediction is ever painted there.
|
||||||
|
const CODEX_COMPOSER_ROW_RE = /^› /;
|
||||||
|
|
||||||
|
// Classify onData for the predictive echo hook. Terminal query responses
|
||||||
|
// never reach this (suppressed earlier in onData); bracketed pastes, nav
|
||||||
|
// keys and mouse reports all start with ESC => 'clear'.
|
||||||
|
function classifyPredictInput(data) {
|
||||||
|
const cps = Array.from(data); // astral-safe
|
||||||
|
if (cps.length === 1) {
|
||||||
|
const cp = cps[0].codePointAt(0);
|
||||||
|
if (cp === 0x7f) return 'backspace';
|
||||||
|
if (cp >= 0x20) return 'char'; // incl. a single astral emoji
|
||||||
|
return 'clear'; // \r \n \t \x03, bare ESC, ...
|
||||||
|
}
|
||||||
|
if (data.charCodeAt(0) === 0x1b) return 'clear'; // ESC seq: nav, paste, mouse SGR
|
||||||
|
if (data.charCodeAt(0) >= 0x20) return 'text'; // multi-char printable (plain paste,
|
||||||
|
return 'clear'; // ZWJ emoji cluster): wire only, no visual
|
||||||
|
}
|
||||||
|
|
||||||
|
// Predictive-echo gate: predict only while the cursor sits on the codex
|
||||||
|
// composer row. cursorY is baseY-relative (xterm API), hence baseY + cursorY.
|
||||||
|
function isCodexComposerRow(terminal) {
|
||||||
|
try {
|
||||||
|
const buf = terminal.buffer.active;
|
||||||
|
const line = buf.getLine(buf.baseY + buf.cursorY);
|
||||||
|
return !!line && CODEX_COMPOSER_ROW_RE.test(line.translateToString(true));
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function isTerminalQueryResponse(data) {
|
function isTerminalQueryResponse(data) {
|
||||||
return TERMINAL_QUERY_RESPONSE_PATTERN.test(data) || TERMINAL_OSC_RESPONSE_PATTERN.test(data);
|
return TERMINAL_QUERY_RESPONSE_PATTERN.test(data) || TERMINAL_OSC_RESPONSE_PATTERN.test(data);
|
||||||
}
|
}
|
||||||
@@ -99,6 +135,9 @@
|
|||||||
isTerminalQueryResponse,
|
isTerminalQueryResponse,
|
||||||
shouldSuppressTerminalQueryResponse,
|
shouldSuppressTerminalQueryResponse,
|
||||||
isComposerNavKey,
|
isComposerNavKey,
|
||||||
|
classifyPredictInput,
|
||||||
|
isCodexComposerRow,
|
||||||
|
CODEX_COMPOSER_ROW_RE,
|
||||||
BRACKETED_PASTE_START,
|
BRACKETED_PASTE_START,
|
||||||
USER_SCROLL_STICKY_SUPPRESS_MS,
|
USER_SCROLL_STICKY_SUPPRESS_MS,
|
||||||
TOUCH_COMPAT_MOUSE_SUPPRESS_MS,
|
TOUCH_COMPAT_MOUSE_SUPPRESS_MS,
|
||||||
@@ -399,6 +438,12 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this._localEchoOverlay = new LocalEchoOverlay(this.terminal);
|
this._localEchoOverlay = new LocalEchoOverlay(this.terminal);
|
||||||
|
// Predictive write-through echo (codex): separate opt-in bundle
|
||||||
|
// (vendor/xterm-predictive-echo.js); when it is missing or failed to
|
||||||
|
// load, codex falls back to plain PTY echo exactly like 1.12.2.
|
||||||
|
this._predictiveEcho =
|
||||||
|
typeof PredictiveEchoOverlay !== 'undefined' ? new PredictiveEchoOverlay(this.terminal) : null;
|
||||||
|
this._predictiveEcho?.setPredictWhen((terminal) => window.CodemanTerminalInput.isCodexComposerRow(terminal));
|
||||||
if (MobileDetection.isTouchDevice()) {
|
if (MobileDetection.isTouchDevice()) {
|
||||||
this.terminal.onCursorMove(() => this._syncMobileHelperTextareaToCursor());
|
this.terminal.onCursorMove(() => this._syncMobileHelperTextareaToCursor());
|
||||||
this.terminal.onRender(() => this._syncMobileHelperTextareaToCursor());
|
this.terminal.onRender(() => this._syncMobileHelperTextareaToCursor());
|
||||||
@@ -1108,6 +1153,13 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Predictive Echo (codex): visual only. A plain statement, never a
|
||||||
|
// `return`: control ALWAYS falls through into the send path below,
|
||||||
|
// which is the byte-identity guarantee for #218/#219/#220/#222 —
|
||||||
|
// with the predictor active, absent or throwing, the wire sees the
|
||||||
|
// same bytes. Body in _predictHookOnData (vm-testable).
|
||||||
|
this._predictHookOnData(data);
|
||||||
|
|
||||||
// ── Normal Mode (echo disabled) ──
|
// ── Normal Mode (echo disabled) ──
|
||||||
this._pendingInput += data;
|
this._pendingInput += data;
|
||||||
|
|
||||||
@@ -2450,8 +2502,9 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
// grows and rewraps as it fills (#220), pastes are bracketed (#219)
|
// grows and rewraps as it fills (#220), pastes are bracketed (#219)
|
||||||
// and arrows/history edit server-side state (#218). Buffering
|
// and arrows/history edit server-side state (#218). Buffering
|
||||||
// keystrokes until Enter starves all of that, so codex sessions use
|
// keystrokes until Enter starves all of that, so codex sessions use
|
||||||
// plain PTY echo like shell.
|
// plain PTY echo like shell — visually augmented by the predictive
|
||||||
// Disable it by clearing any pending text.
|
// write-through echo (see _localEchoPolicy below and the onData hook).
|
||||||
|
// Disable the buffer overlay by clearing any pending text.
|
||||||
this._localEchoOverlay.clear();
|
this._localEchoOverlay.clear();
|
||||||
this._localEchoEnabled = false;
|
this._localEchoEnabled = false;
|
||||||
} else {
|
} else {
|
||||||
@@ -2484,6 +2537,39 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Per-session echo policy: 'buffer' (overlay), 'predict' (codex
|
||||||
|
// write-through, see the onData predict hook), 'off'. _localEchoEnabled
|
||||||
|
// keeps its exact historical values above (false for codex/shell), so
|
||||||
|
// every existing consumer is unchanged; this field is purely additive.
|
||||||
|
let policy = 'off';
|
||||||
|
if (session && echoEnabled) {
|
||||||
|
if (session.mode === 'codex') policy = 'predict';
|
||||||
|
else if (session.mode !== 'shell') policy = 'buffer';
|
||||||
|
}
|
||||||
|
this._localEchoPolicy = policy;
|
||||||
|
if (policy !== 'predict') this._predictiveEcho?.clearPredictions();
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Predictive-echo onData hook (codex write-through). VISUAL ONLY: paints,
|
||||||
|
* pops or clears prediction spans and never touches _pendingInput, never
|
||||||
|
* sends, never throws into the caller. The onData wire path behaves
|
||||||
|
* byte-identically with this active, absent or broken.
|
||||||
|
*/
|
||||||
|
_predictHookOnData(data) {
|
||||||
|
if (this._localEchoPolicy !== 'predict' || !this._predictiveEcho) return;
|
||||||
|
try {
|
||||||
|
const kind = window.CodemanTerminalInput.classifyPredictInput(data);
|
||||||
|
if (kind === 'char') this._predictiveEcho.predictChar(data);
|
||||||
|
else if (kind === 'backspace') this._predictiveEcho.predictBackspace();
|
||||||
|
// 'clear' AND 'text' (plain paste, IME word commits) both change the
|
||||||
|
// composer in ways the display has not shown yet: clear the run and let
|
||||||
|
// the addon's anchor hold suppress prediction until the echo catches up
|
||||||
|
else this._predictiveEcho.clearPredictions();
|
||||||
|
} catch {
|
||||||
|
/* predictions must never block the wire */
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// CJK textarea already provides visual feedback — bypass local echo
|
// CJK textarea already provides visual feedback — bypass local echo
|
||||||
@@ -2493,6 +2579,8 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
_crashDiag.log(`CJK send DROP no-session len=${text.length}`);
|
_crashDiag.log(`CJK send DROP no-session len=${text.length}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Bypasses onData (like insertTerminalText): predictions cannot see this
|
||||||
|
if (this._localEchoPolicy === 'predict') this._predictiveEcho?.clearPredictions();
|
||||||
_crashDiag.log(`CJK send→${this.activeSessionId.slice(0, 8)} len=${text.length}`);
|
_crashDiag.log(`CJK send→${this.activeSessionId.slice(0, 8)} len=${text.length}`);
|
||||||
this._sendInputAsync(this.activeSessionId, text);
|
this._sendInputAsync(this.activeSessionId, text);
|
||||||
},
|
},
|
||||||
@@ -2848,6 +2936,9 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
/** Insert editable text at the active prompt without pressing Enter. */
|
/** Insert editable text at the active prompt without pressing Enter. */
|
||||||
insertTerminalText(text) {
|
insertTerminalText(text) {
|
||||||
if (!this.activeSessionId || !text) return;
|
if (!this.activeSessionId || !text) return;
|
||||||
|
// Under predict the text goes out via sendInput (bypasses onData), so the
|
||||||
|
// hook never sees it: clear outstanding predictions here instead.
|
||||||
|
if (this._localEchoPolicy === 'predict') this._predictiveEcho?.clearPredictions();
|
||||||
if (
|
if (
|
||||||
this._localEchoEnabled &&
|
this._localEchoEnabled &&
|
||||||
this._localEchoOverlay &&
|
this._localEchoOverlay &&
|
||||||
@@ -2873,6 +2964,8 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
this._inputFlushTimeout = null;
|
this._inputFlushTimeout = null;
|
||||||
}
|
}
|
||||||
this._pendingInput = '';
|
this._pendingInput = '';
|
||||||
|
// Composer content is about to change out from under any predictions
|
||||||
|
if (this._localEchoPolicy === 'predict') this._predictiveEcho?.clearPredictions();
|
||||||
|
|
||||||
if (this._localEchoEnabled && this._localEchoOverlay) {
|
if (this._localEchoEnabled && this._localEchoOverlay) {
|
||||||
const flushed = this._localEchoOverlay.getFlushed?.() || { count: 0, text: '' };
|
const flushed = this._localEchoOverlay.getFlushed?.() || { count: 0, text: '' };
|
||||||
@@ -3379,6 +3472,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
localStorage.setItem('codeman-font-size', size);
|
localStorage.setItem('codeman-font-size', size);
|
||||||
// Update overlay font cache and re-render at new cell dimensions
|
// Update overlay font cache and re-render at new cell dimensions
|
||||||
this._localEchoOverlay?.refreshFont();
|
this._localEchoOverlay?.refreshFont();
|
||||||
|
this._predictiveEcho?.refreshFont();
|
||||||
},
|
},
|
||||||
|
|
||||||
loadFontSize() {
|
loadFontSize() {
|
||||||
@@ -3513,6 +3607,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
// Refresh it on live skin changes so typed text never keeps the prior
|
// Refresh it on live skin changes so typed text never keeps the prior
|
||||||
// theme's dark backing surface or foreground color.
|
// theme's dark backing surface or foreground color.
|
||||||
this._localEchoOverlay?.refreshFont();
|
this._localEchoOverlay?.refreshFont();
|
||||||
|
this._predictiveEcho?.refreshFont();
|
||||||
try {
|
try {
|
||||||
this.terminal.refresh(0, this.terminal.rows - 1);
|
this.terminal.refresh(0, this.terminal.rows - 1);
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|||||||
@@ -612,6 +612,9 @@ const VoiceInput = {
|
|||||||
if (text) app.sendInput(text).catch(() => {});
|
if (text) app.sendInput(text).catch(() => {});
|
||||||
setTimeout(() => app.sendInput('\r').catch(() => {}), 80);
|
setTimeout(() => app.sendInput('\r').catch(() => {}), 80);
|
||||||
} else {
|
} else {
|
||||||
|
// Predict-mode sessions (codex) take this branch: the send bypasses
|
||||||
|
// onData, so clear outstanding predictions here (composer will reset)
|
||||||
|
app._predictiveEcho?.clearPredictions();
|
||||||
app.sendInput('\r').catch(() => {});
|
app.sendInput('\r').catch(() => {});
|
||||||
}
|
}
|
||||||
// Blink then restore
|
// Blink then restore
|
||||||
|
|||||||
@@ -0,0 +1,606 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Predictive-echo E2E against a REAL codex 0.147 TUI (issues
|
||||||
|
* #218/#219/#220/#222 retest scenarios + the byte-identity and simulated-RTT
|
||||||
|
* pins). CI-EXCLUDED (needs chromium + the codex binary); a REQUIRED item of
|
||||||
|
* the release checklist.
|
||||||
|
*
|
||||||
|
* Unlike the other Playwright tests this one cannot use the in-process
|
||||||
|
* WebServer: under VITEST the mux layer is a pure in-memory mock and Session
|
||||||
|
* spawns an echo PTY, so no real codex would ever run. The lab server is a
|
||||||
|
* CHILD PROCESS with the VITEST markers stripped from its env, isolated via
|
||||||
|
* CODEMAN_INSTANCE=codexlab (own data dir under the per-test fixture HOME +
|
||||||
|
* own tmux socket) on port 3222 (3220/3221 are taken; see the port sweep in
|
||||||
|
* the plan). Codex runs against a throwaway CODEX_HOME with a fake API key
|
||||||
|
* (never leaves the box: the first request 401s, which is fine — every
|
||||||
|
* scenario here is about the composer, not completions).
|
||||||
|
*
|
||||||
|
* Run: npx vitest run --config config/vitest.config.ts test/codex-predictive-echo.test.ts
|
||||||
|
*/
|
||||||
|
import { execSync, spawn, type ChildProcess } from 'node:child_process';
|
||||||
|
import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs';
|
||||||
|
import { userInfo } from 'node:os';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
import { chromium, type Browser, type BrowserContext, type Page } from 'playwright';
|
||||||
|
|
||||||
|
const PORT = 3222;
|
||||||
|
const BASE_URL = `http://localhost:${PORT}`;
|
||||||
|
const INSTANCE = 'codexlab';
|
||||||
|
const TMUX = `tmux -L codeman-${INSTANCE}`;
|
||||||
|
const ROOT = resolve(import.meta.dirname, '..');
|
||||||
|
const CODEX_BIN_DIR = `${userInfo().homedir}/.local/bin`;
|
||||||
|
|
||||||
|
let server: ChildProcess | null = null;
|
||||||
|
let browser: Browser;
|
||||||
|
let context: BrowserContext;
|
||||||
|
let page: Page;
|
||||||
|
let codexHome: string;
|
||||||
|
let trustedWorkdir: string;
|
||||||
|
let untrustedWorkdir: string;
|
||||||
|
let sessionId: string;
|
||||||
|
const createdSessions: string[] = [];
|
||||||
|
|
||||||
|
function hasCodex(): boolean {
|
||||||
|
try {
|
||||||
|
execSync(`PATH="${CODEX_BIN_DIR}:$PATH" codex --version`, { stdio: 'pipe' });
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[codex-e2e] codex unavailable:', (e as Error).message.slice(0, 300));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function api(method: string, path: string, body?: unknown): Promise<any> {
|
||||||
|
const res = await fetch(`${BASE_URL}${path}`, {
|
||||||
|
method,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: body === undefined ? undefined : JSON.stringify(body),
|
||||||
|
});
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
const paneByTail = new Map<string, string>();
|
||||||
|
|
||||||
|
/** Rendered pane text (tmux ground truth). Panes are found once by the
|
||||||
|
* workdir basename codex prints in its banner, then cached by pane id —
|
||||||
|
* the lab tmux socket is exclusive to this test. */
|
||||||
|
function capturePaneFor(workdir: string): string {
|
||||||
|
const tail = workdir.split('/').pop()!;
|
||||||
|
const panes = execSync(`${TMUX} list-panes -a -F '#{pane_id}'`, { encoding: 'utf8' }).trim().split('\n');
|
||||||
|
const capture = (p: string) => execSync(`${TMUX} capture-pane -p -t '${p}'`, { encoding: 'utf8' });
|
||||||
|
const cached = paneByTail.get(tail);
|
||||||
|
if (cached && panes.includes(cached)) return capture(cached);
|
||||||
|
for (const p of panes.filter(Boolean)) {
|
||||||
|
const text = capture(p);
|
||||||
|
if (text.includes(tail)) {
|
||||||
|
paneByTail.set(tail, p);
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error(`no pane showing workdir ${tail}; panes: ${panes.join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createCodexSession(workingDir: string): Promise<string> {
|
||||||
|
const created = await api('POST', '/api/sessions', {
|
||||||
|
mode: 'codex',
|
||||||
|
workingDir,
|
||||||
|
envOverrides: { CODEX_HOME: codexHome },
|
||||||
|
});
|
||||||
|
// Tolerate both the ApiResponse envelope and the legacy raw shape
|
||||||
|
const payload = created.data ?? created;
|
||||||
|
const id = payload.session?.id ?? payload.id ?? payload.sessionId;
|
||||||
|
expect(id, JSON.stringify(created).slice(0, 300)).toBeTruthy();
|
||||||
|
createdSessions.push(id);
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Select the session in the UI and wait for the codex composer to render. */
|
||||||
|
async function openInBrowser(id: string): Promise<void> {
|
||||||
|
await page.evaluate((sid) => (window as any).app.selectSession(sid), id);
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => {
|
||||||
|
const app = (window as any).app;
|
||||||
|
const buf = app.terminal?.buffer.active;
|
||||||
|
if (!buf) return false;
|
||||||
|
for (let y = 0; y < app.terminal.rows; y++) {
|
||||||
|
const t = buf.getLine(buf.baseY + y)?.translateToString(true) ?? '';
|
||||||
|
if (/^› /.test(t)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
{ timeout: 30000 }
|
||||||
|
);
|
||||||
|
await page.locator('#terminalContainer').click({ position: { x: 200, y: 200 } });
|
||||||
|
await page.waitForFunction(() => (window as any).app._localEchoPolicy === 'predict');
|
||||||
|
// Wait for the exact typing precondition: the CURSOR parked on the composer
|
||||||
|
// row (the predictWhen gate itself), not merely a composer row existing —
|
||||||
|
// during the boot animation the cursor roams and predictions are suppressed.
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => {
|
||||||
|
const w = window as any;
|
||||||
|
return w.CodemanTerminalInput.isCodexComposerRow(w.app.terminal);
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
{ timeout: 20000 }
|
||||||
|
);
|
||||||
|
await new Promise((r) => setTimeout(r, 300));
|
||||||
|
}
|
||||||
|
|
||||||
|
function predictState(): Promise<{ outstanding: number; confirmedTotal: number; droppedTotal: number }> {
|
||||||
|
return page.evaluate(() => (window as any).app._predictiveEcho.state);
|
||||||
|
}
|
||||||
|
|
||||||
|
function spanCount(): Promise<number> {
|
||||||
|
return page.evaluate(() => document.querySelectorAll('.xterm-screen [data-predictive-echo] span').length);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Deterministic composer reset: codex's Ctrl+U kills only to LINE START, so
|
||||||
|
* End first; then a settle. Leaves the cursor parked on an empty composer. */
|
||||||
|
async function resetComposer(): Promise<void> {
|
||||||
|
// Codex can be mid-respawn after an exhausted 401 retry loop killed it:
|
||||||
|
// wait for a live composer (the gate itself) before touching the keyboard.
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => {
|
||||||
|
const w = window as any;
|
||||||
|
return w.CodemanTerminalInput.isCodexComposerRow(w.app.terminal);
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
{ timeout: 25000 }
|
||||||
|
);
|
||||||
|
await page.locator('#terminalContainer').click({ position: { x: 200, y: 200 } });
|
||||||
|
await page.keyboard.press('End');
|
||||||
|
await page.keyboard.press('Control+u');
|
||||||
|
await new Promise((r) => setTimeout(r, 400));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** After a submit 401s, codex sits in a Reconnecting retry loop that can eat
|
||||||
|
* typed input; Esc interrupts it. Wait until the retry line is gone. */
|
||||||
|
async function cancelRetryLoop(): Promise<void> {
|
||||||
|
const deadline = Date.now() + 15000;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
const pane = capturePaneFor(trustedWorkdir);
|
||||||
|
if (!/Reconnecting|esc to interrupt/.test(pane)) break;
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
await new Promise((r) => setTimeout(r, 500));
|
||||||
|
}
|
||||||
|
// Settle to a live composer (codex may have died at 5/5 and respawned),
|
||||||
|
// then require it to STAY alive: the fake-key request can kill codex
|
||||||
|
// seconds later, so a single gate-true observation is not enough.
|
||||||
|
const stableDeadline = Date.now() + 30000;
|
||||||
|
for (;;) {
|
||||||
|
await resetComposer();
|
||||||
|
let stable = true;
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
await new Promise((r) => setTimeout(r, 1000));
|
||||||
|
const alive = await page.evaluate(() => {
|
||||||
|
const w = window as any;
|
||||||
|
return w.CodemanTerminalInput.isCodexComposerRow(w.app.terminal);
|
||||||
|
});
|
||||||
|
if (!alive) {
|
||||||
|
stable = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (stable || Date.now() > stableDeadline) return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const CODEX_AVAILABLE = hasCodex();
|
||||||
|
const d = CODEX_AVAILABLE ? describe : describe.skip;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
if (!CODEX_AVAILABLE) return;
|
||||||
|
codexHome = mkdtempSync(resolve(ROOT, 'tmp', 'codexlab-'));
|
||||||
|
trustedWorkdir = mkdtempSync(resolve(ROOT, 'tmp', 'codexlab-work-'));
|
||||||
|
untrustedWorkdir = mkdtempSync(resolve(ROOT, 'tmp', 'codexlab-untrusted-'));
|
||||||
|
mkdirSync(codexHome, { recursive: true });
|
||||||
|
writeFileSync(resolve(codexHome, 'auth.json'), JSON.stringify({ OPENAI_API_KEY: 'sk-test-123' }));
|
||||||
|
writeFileSync(resolve(codexHome, 'config.toml'), `[projects."${trustedWorkdir}"]\ntrust_level = "trusted"\n`);
|
||||||
|
|
||||||
|
// Child env: strip the vitest markers so the lab server runs REAL tmux/codex
|
||||||
|
const env: Record<string, string | undefined> = { ...process.env };
|
||||||
|
delete env.VITEST;
|
||||||
|
delete env.VITEST_MODE;
|
||||||
|
delete env.VITEST_POOL_ID;
|
||||||
|
delete env.VITEST_WORKER_ID;
|
||||||
|
delete env.NODE_ENV;
|
||||||
|
env.CODEMAN_INSTANCE = INSTANCE;
|
||||||
|
env.PATH = `${CODEX_BIN_DIR}:${env.PATH}`;
|
||||||
|
|
||||||
|
server = spawn('npx', ['tsx', 'src/index.ts', 'web', '--port', String(PORT)], {
|
||||||
|
cwd: ROOT,
|
||||||
|
env: env as NodeJS.ProcessEnv,
|
||||||
|
stdio: 'ignore',
|
||||||
|
detached: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wait for the lab server
|
||||||
|
const deadline = Date.now() + 30000;
|
||||||
|
for (;;) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${BASE_URL}/api/status`);
|
||||||
|
if (res.ok) break;
|
||||||
|
} catch {
|
||||||
|
/* not up yet */
|
||||||
|
}
|
||||||
|
if (Date.now() > deadline) throw new Error('lab server did not start on :3222');
|
||||||
|
await new Promise((r) => setTimeout(r, 300));
|
||||||
|
}
|
||||||
|
|
||||||
|
browser = await chromium.launch({ headless: true });
|
||||||
|
context = await browser.newContext({ viewport: { width: 1280, height: 800 } });
|
||||||
|
page = await context.newPage();
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
localStorage.setItem('codeman-app-settings', JSON.stringify({ localEchoEnabled: true }));
|
||||||
|
});
|
||||||
|
await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' });
|
||||||
|
await page.waitForFunction(() => document.body.classList.contains('app-loaded'), { timeout: 10000 });
|
||||||
|
|
||||||
|
sessionId = await createCodexSession(trustedWorkdir);
|
||||||
|
await openInBrowser(sessionId);
|
||||||
|
}, 120_000);
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
for (const id of createdSessions) {
|
||||||
|
try {
|
||||||
|
await api('DELETE', `/api/sessions/${id}`);
|
||||||
|
} catch {
|
||||||
|
/* best effort */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await context?.close();
|
||||||
|
await browser?.close();
|
||||||
|
server?.kill('SIGTERM');
|
||||||
|
try {
|
||||||
|
execSync(`${TMUX} kill-server`, { stdio: 'ignore' });
|
||||||
|
} catch {
|
||||||
|
/* already gone */
|
||||||
|
}
|
||||||
|
}, 30_000);
|
||||||
|
|
||||||
|
d('codex predictive echo E2E (real codex TUI)', () => {
|
||||||
|
it('bundle smoke: both echo globals are defined', async () => {
|
||||||
|
const globals = await page.evaluate(() => ({
|
||||||
|
zerolag: typeof (window as any).LocalEchoOverlay,
|
||||||
|
predictive: typeof (window as any).PredictiveEchoOverlay,
|
||||||
|
instance: !!(window as any).app._predictiveEcho,
|
||||||
|
}));
|
||||||
|
expect(globals.zerolag).toBe('function');
|
||||||
|
expect(globals.predictive).toBe('function');
|
||||||
|
expect(globals.instance).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('typing predicts every char and converges into the real echo', async () => {
|
||||||
|
await page.keyboard.type('hello', { delay: 30 });
|
||||||
|
// Keystrokes went through the predictor (a transient repaint may suppress
|
||||||
|
// one or two: that is designed graceful degradation, never a ghost)
|
||||||
|
await page.waitForFunction(() => {
|
||||||
|
const s = (window as any).app._predictiveEcho.state;
|
||||||
|
return s.confirmedTotal + s.outstanding >= 3;
|
||||||
|
});
|
||||||
|
// Convergence: spans gone, composer shows the text, ZERO mispredictions
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => document.querySelectorAll('.xterm-screen [data-predictive-echo] span').length === 0
|
||||||
|
);
|
||||||
|
const s = await predictState();
|
||||||
|
expect(s.confirmedTotal).toBeGreaterThanOrEqual(3);
|
||||||
|
expect(s.droppedTotal).toBe(0);
|
||||||
|
const deadline = Date.now() + 10000;
|
||||||
|
let pane = '';
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
pane = capturePaneFor(trustedWorkdir);
|
||||||
|
if (pane.includes('hello')) break;
|
||||||
|
await new Promise((r) => setTimeout(r, 250));
|
||||||
|
}
|
||||||
|
expect(pane).toContain('hello');
|
||||||
|
}, 30_000);
|
||||||
|
|
||||||
|
it('#222: slash picker filters live while predictions confirm away', async () => {
|
||||||
|
await resetComposer();
|
||||||
|
await page.keyboard.type('/', { delay: 30 });
|
||||||
|
const deadline = Date.now() + 10000;
|
||||||
|
let pane = '';
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
pane = capturePaneFor(trustedWorkdir);
|
||||||
|
if (/\/model|\/skills|\/init/.test(pane)) break;
|
||||||
|
await new Promise((r) => setTimeout(r, 250));
|
||||||
|
}
|
||||||
|
expect(pane).toMatch(/\/model|\/skills|\/init/);
|
||||||
|
await page.keyboard.type('mo', { delay: 40 });
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => document.querySelectorAll('.xterm-screen [data-predictive-echo] span').length === 0
|
||||||
|
);
|
||||||
|
const filtered = capturePaneFor(trustedWorkdir);
|
||||||
|
expect(filtered).toContain('/model');
|
||||||
|
await page.keyboard.press('Escape'); // close the picker
|
||||||
|
await page.keyboard.press('Control+u'); // deterministically empty the composer
|
||||||
|
await new Promise((r) => setTimeout(r, 300));
|
||||||
|
const s = await predictState();
|
||||||
|
expect(s.outstanding).toBe(0);
|
||||||
|
}, 30_000);
|
||||||
|
|
||||||
|
it('#219: typed text + paste land in order with nothing dropped', async () => {
|
||||||
|
await resetComposer();
|
||||||
|
const pidBefore = execSync(`${TMUX} list-panes -a -F '#{pane_id} #{pane_pid}'`, { encoding: 'utf8' }).trim();
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const app = (window as any).app;
|
||||||
|
(window as any).__dbg = [];
|
||||||
|
if (!(window as any).__origSend2) (window as any).__origSend2 = app._sendInputAsync.bind(app);
|
||||||
|
app._sendInputAsync = (sid: string, data: string, opts?: unknown) => {
|
||||||
|
(window as any).__dbg.push([sid.slice(0, 8), JSON.stringify(data)]);
|
||||||
|
return (window as any).__origSend2(sid, data, opts);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
await page.keyboard.type('abc', { delay: 30 });
|
||||||
|
await page.evaluate(() => (window as any).app.terminal.paste('XYZ'));
|
||||||
|
const deadline = Date.now() + 15000;
|
||||||
|
let pane = '';
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
pane = capturePaneFor(trustedWorkdir);
|
||||||
|
if (pane.includes('abcXYZ')) break;
|
||||||
|
await new Promise((r) => setTimeout(r, 250));
|
||||||
|
}
|
||||||
|
if (!pane.includes('abcXYZ')) {
|
||||||
|
const pidAfter = execSync(`${TMUX} list-panes -a -F '#{pane_id} #{pane_pid}'`, { encoding: 'utf8' }).trim();
|
||||||
|
const dbg = await page.evaluate(() => (window as any).__dbg);
|
||||||
|
const row = await page.evaluate(() => {
|
||||||
|
const b = (window as any).app.terminal.buffer.active;
|
||||||
|
return b.getLine(b.baseY + b.cursorY)?.translateToString(true);
|
||||||
|
});
|
||||||
|
console.log(
|
||||||
|
'DBG219 pids-before:',
|
||||||
|
pidBefore,
|
||||||
|
'| pids-after:',
|
||||||
|
pidAfter,
|
||||||
|
'| sends:',
|
||||||
|
JSON.stringify(dbg),
|
||||||
|
'| app-cursor-row:',
|
||||||
|
JSON.stringify(row)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
expect(pane).toContain('abcXYZ');
|
||||||
|
expect(await spanCount()).toBe(0); // paste classified 'clear'
|
||||||
|
await page.keyboard.press('Control+u'); // Ctrl+U: clear the composer line
|
||||||
|
}, 30_000);
|
||||||
|
|
||||||
|
it('#220: wrapped input renders exactly, no ghost glyphs past the edge', async () => {
|
||||||
|
await resetComposer();
|
||||||
|
const long = 'the quick brown fox jumps over the lazy dog and keeps running until the composer has to wrap';
|
||||||
|
await page.keyboard.type(long, { delay: 5 });
|
||||||
|
// Predictions on the first composer row confirm or drop; continuation
|
||||||
|
// rows are gate-suppressed. Everything converges:
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => document.querySelectorAll('.xterm-screen [data-predictive-echo] span').length === 0,
|
||||||
|
undefined,
|
||||||
|
{ timeout: 10000 }
|
||||||
|
);
|
||||||
|
const deadline = Date.now() + 10000;
|
||||||
|
let ok = false;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
const pane = capturePaneFor(trustedWorkdir).replace(/\s+/g, ' ');
|
||||||
|
if (pane.includes('composer has to wrap')) {
|
||||||
|
ok = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
await new Promise((r) => setTimeout(r, 250));
|
||||||
|
}
|
||||||
|
expect(ok).toBe(true);
|
||||||
|
await page.keyboard.press('Control+u');
|
||||||
|
await new Promise((r) => setTimeout(r, 200));
|
||||||
|
}, 45_000);
|
||||||
|
|
||||||
|
it('modal ghost eliminator: zero spans while typing on the trust dialog', async () => {
|
||||||
|
const modalSession = await createCodexSession(untrustedWorkdir);
|
||||||
|
try {
|
||||||
|
await page.evaluate((sid) => (window as any).app.selectSession(sid), modalSession);
|
||||||
|
// Wait for the trust dialog (the pane may not exist for the first second)
|
||||||
|
const deadline = Date.now() + 25000;
|
||||||
|
let pane = '';
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
try {
|
||||||
|
pane = capturePaneFor(untrustedWorkdir);
|
||||||
|
if (pane.includes('Press enter to continue')) break;
|
||||||
|
} catch {
|
||||||
|
/* session still spawning */
|
||||||
|
}
|
||||||
|
await new Promise((r) => setTimeout(r, 300));
|
||||||
|
}
|
||||||
|
expect(pane).toContain('Press enter to continue');
|
||||||
|
await page.waitForFunction(() => (window as any).app._localEchoPolicy === 'predict');
|
||||||
|
await page.locator('#terminalContainer').click({ position: { x: 200, y: 200 } });
|
||||||
|
await page.keyboard.type('x', { delay: 30 });
|
||||||
|
expect(await spanCount()).toBe(0); // gate rejected: no ghost on the modal
|
||||||
|
expect((await predictState()).outstanding).toBe(0);
|
||||||
|
} finally {
|
||||||
|
// Never strand later tests on the modal session
|
||||||
|
await api('DELETE', `/api/sessions/${modalSession}`);
|
||||||
|
await page.evaluate((sid) => (window as any).app.selectSession(sid), sessionId);
|
||||||
|
await page.waitForFunction(() => (window as any).app._localEchoPolicy === 'predict');
|
||||||
|
await page.locator('#terminalContainer').click({ position: { x: 200, y: 200 } });
|
||||||
|
}
|
||||||
|
}, 45_000);
|
||||||
|
|
||||||
|
it('kill switch: localEchoEnabled OFF clears spans and typing still streams', async () => {
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const app = (window as any).app;
|
||||||
|
const s = app.loadAppSettingsFromStorage();
|
||||||
|
s.localEchoEnabled = false;
|
||||||
|
app.saveAppSettingsToStorage(s);
|
||||||
|
app._updateLocalEchoState();
|
||||||
|
});
|
||||||
|
expect(await page.evaluate(() => (window as any).app._localEchoPolicy)).toBe('off');
|
||||||
|
expect(await spanCount()).toBe(0);
|
||||||
|
await page.locator('#terminalContainer').click({ position: { x: 200, y: 200 } });
|
||||||
|
await page.keyboard.press('End');
|
||||||
|
await page.keyboard.press('Control+u');
|
||||||
|
await page.keyboard.type('still-live', { delay: 20 });
|
||||||
|
const deadline = Date.now() + 10000;
|
||||||
|
let pane = '';
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
pane = capturePaneFor(trustedWorkdir);
|
||||||
|
if (pane.includes('still-live')) break;
|
||||||
|
await new Promise((r) => setTimeout(r, 250));
|
||||||
|
}
|
||||||
|
expect(pane).toContain('still-live'); // 1.12.2 behavior exactly
|
||||||
|
expect(await spanCount()).toBe(0);
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const app = (window as any).app;
|
||||||
|
const s = app.loadAppSettingsFromStorage();
|
||||||
|
s.localEchoEnabled = true;
|
||||||
|
app.saveAppSettingsToStorage(s);
|
||||||
|
app._updateLocalEchoState();
|
||||||
|
});
|
||||||
|
await page.keyboard.press('Control+u');
|
||||||
|
await new Promise((r) => setTimeout(r, 200));
|
||||||
|
}, 30_000);
|
||||||
|
|
||||||
|
it('byte-identity: the wire receives the same bytes with the predictor active vs absent', async () => {
|
||||||
|
const script = async () => {
|
||||||
|
await resetComposer();
|
||||||
|
await page.keyboard.type('ab', { delay: 60 });
|
||||||
|
await page.keyboard.press('Backspace');
|
||||||
|
await page.keyboard.press('ArrowLeft');
|
||||||
|
await page.keyboard.type('c', { delay: 60 });
|
||||||
|
await page.evaluate(() => (window as any).app.terminal.paste('PQ'));
|
||||||
|
await new Promise((r) => setTimeout(r, 250));
|
||||||
|
await page.keyboard.press('End');
|
||||||
|
await page.keyboard.press('Control+u');
|
||||||
|
await new Promise((r) => setTimeout(r, 250));
|
||||||
|
};
|
||||||
|
|
||||||
|
const record = () =>
|
||||||
|
page.evaluate(() => {
|
||||||
|
const app = (window as any).app;
|
||||||
|
(window as any).__trace = [];
|
||||||
|
if (!(window as any).__origSend) (window as any).__origSend = app._sendInputAsync.bind(app);
|
||||||
|
app._sendInputAsync = (sid: string, data: string, opts?: unknown) => {
|
||||||
|
(window as any).__trace.push(data);
|
||||||
|
return (window as any).__origSend(sid, data, opts);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const trace = () => page.evaluate(() => ((window as any).__trace as string[]).join(''));
|
||||||
|
|
||||||
|
await record();
|
||||||
|
await script();
|
||||||
|
const withPredictor = await trace();
|
||||||
|
|
||||||
|
await page.evaluate(() => {
|
||||||
|
(window as any).__savedPredictor = (window as any).app._predictiveEcho;
|
||||||
|
(window as any).app._predictiveEcho = null;
|
||||||
|
});
|
||||||
|
await record();
|
||||||
|
await script();
|
||||||
|
const withoutPredictor = await trace();
|
||||||
|
await page.evaluate(() => {
|
||||||
|
(window as any).app._predictiveEcho = (window as any).__savedPredictor;
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(withPredictor.length).toBeGreaterThan(0);
|
||||||
|
expect(withoutPredictor).toBe(withPredictor); // the visual-only invariant, end to end
|
||||||
|
}, 60_000);
|
||||||
|
|
||||||
|
it('#218: arrows + mid-word insert submit the exact edited text', async () => {
|
||||||
|
await resetComposer();
|
||||||
|
await page.keyboard.type('hello', { delay: 30 });
|
||||||
|
await new Promise((r) => setTimeout(r, 300));
|
||||||
|
await page.keyboard.press('ArrowLeft');
|
||||||
|
// Nav keys clear predictions immediately (classify 'clear')
|
||||||
|
expect(await predictState()).toMatchObject({ outstanding: 0 });
|
||||||
|
expect(await spanCount()).toBe(0);
|
||||||
|
await page.keyboard.press('ArrowLeft');
|
||||||
|
await page.keyboard.type('X', { delay: 30 });
|
||||||
|
await page.keyboard.press('Enter');
|
||||||
|
// The submitted transcript line carries the edited text
|
||||||
|
const deadline = Date.now() + 15000;
|
||||||
|
let pane = '';
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
pane = capturePaneFor(trustedWorkdir);
|
||||||
|
if (pane.includes('helXlo')) break;
|
||||||
|
await new Promise((r) => setTimeout(r, 300));
|
||||||
|
}
|
||||||
|
expect(pane).toContain('helXlo');
|
||||||
|
expect(await spanCount()).toBe(0);
|
||||||
|
await cancelRetryLoop();
|
||||||
|
}, 45_000);
|
||||||
|
|
||||||
|
it('simulated 300ms RTT: instant spans with correct pixel geometry, exact convergence', async () => {
|
||||||
|
await resetComposer();
|
||||||
|
// Delay every terminal.write chunk by 300ms: display-side injection only,
|
||||||
|
// the wire is untouched. This is the condition the feature exists for.
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const app = (window as any).app;
|
||||||
|
(window as any).__origWrite = app.terminal.write.bind(app.terminal);
|
||||||
|
app.terminal.write = (data: unknown, cb?: () => void) =>
|
||||||
|
setTimeout(() => (window as any).__origWrite(data, cb), 300);
|
||||||
|
});
|
||||||
|
// Let any pre-wrapper chunks and their reconcile passes settle first
|
||||||
|
await new Promise((r) => setTimeout(r, 700));
|
||||||
|
const base = await predictState();
|
||||||
|
|
||||||
|
await page.keyboard.press('h');
|
||||||
|
// The span exists NOW, long before the delayed echo can land
|
||||||
|
const snap = await page.evaluate(() => {
|
||||||
|
const app = (window as any).app;
|
||||||
|
const span = document.querySelector('.xterm-screen [data-predictive-echo] span') as HTMLElement;
|
||||||
|
const screen = document.querySelector('.xterm-screen') as HTMLElement;
|
||||||
|
const dims = app.terminal._core._renderService.dimensions.css.cell;
|
||||||
|
const buf = app.terminal.buffer.active;
|
||||||
|
return span
|
||||||
|
? {
|
||||||
|
outstanding: app._predictiveEcho.state.outstanding,
|
||||||
|
spanLeft: span.getBoundingClientRect().left - screen.getBoundingClientRect().left,
|
||||||
|
spanTop: span.getBoundingClientRect().top - screen.getBoundingClientRect().top,
|
||||||
|
expectedLeft: buf.cursorX * dims.width,
|
||||||
|
expectedTop: buf.cursorY * dims.height,
|
||||||
|
text: span.textContent,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
});
|
||||||
|
expect(snap).not.toBeNull();
|
||||||
|
expect(snap!.outstanding).toBe(1);
|
||||||
|
expect(snap!.text).toBe('h');
|
||||||
|
// Pixel geometry: the DOM span sits on the exact cell the echo will use
|
||||||
|
expect(Math.abs(snap!.spanLeft - snap!.expectedLeft)).toBeLessThan(1.5);
|
||||||
|
expect(Math.abs(snap!.spanTop - snap!.expectedTop)).toBeLessThan(1.5);
|
||||||
|
|
||||||
|
await page.keyboard.type('igh rtt', { delay: 40 });
|
||||||
|
// (No mid-flight outstanding assertion: with local codex the delayed echo
|
||||||
|
// begins confirming DURING the typing. The snap above already pinned the
|
||||||
|
// zero-lag property; convergence + the delta below pin the rest.)
|
||||||
|
|
||||||
|
// Full convergence after the delayed echo lands
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => document.querySelectorAll('.xterm-screen [data-predictive-echo] span').length === 0,
|
||||||
|
undefined,
|
||||||
|
{ timeout: 15000 }
|
||||||
|
);
|
||||||
|
const deadline = Date.now() + 10000;
|
||||||
|
let pane = '';
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
pane = capturePaneFor(trustedWorkdir);
|
||||||
|
if (pane.includes('high rtt')) break;
|
||||||
|
await new Promise((r) => setTimeout(r, 250));
|
||||||
|
}
|
||||||
|
expect(pane).toContain('high rtt');
|
||||||
|
const after = await predictState();
|
||||||
|
expect(after.confirmedTotal - base.confirmedTotal).toBeGreaterThanOrEqual(6); // h + igh rtt
|
||||||
|
|
||||||
|
// #218 under RTT: arrows still edit correctly with the delayed display
|
||||||
|
await page.keyboard.press('ArrowLeft');
|
||||||
|
await page.keyboard.type('Z', { delay: 40 });
|
||||||
|
await page.keyboard.press('Enter');
|
||||||
|
const deadline2 = Date.now() + 15000;
|
||||||
|
let pane2 = '';
|
||||||
|
while (Date.now() < deadline2) {
|
||||||
|
pane2 = capturePaneFor(trustedWorkdir);
|
||||||
|
if (pane2.includes('high rtZt')) break;
|
||||||
|
await new Promise((r) => setTimeout(r, 300));
|
||||||
|
}
|
||||||
|
expect(pane2).toContain('high rtZt');
|
||||||
|
|
||||||
|
await page.evaluate(() => {
|
||||||
|
(window as any).app.terminal.write = (window as any).__origWrite;
|
||||||
|
});
|
||||||
|
}, 90_000);
|
||||||
|
});
|
||||||
@@ -238,3 +238,232 @@ describe('insertTerminalText pass-through routing', () => {
|
|||||||
expect(overlay.appendText).not.toHaveBeenCalled();
|
expect(overlay.appendText).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── Predictive write-through echo (codex) ──────────────────────────────────
|
||||||
|
|
||||||
|
type PredictorStub = {
|
||||||
|
predictChar: ReturnType<typeof vi.fn>;
|
||||||
|
predictBackspace: ReturnType<typeof vi.fn>;
|
||||||
|
clearPredictions: ReturnType<typeof vi.fn>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PredictiveApp = AppInstance & {
|
||||||
|
_localEchoPolicy?: string;
|
||||||
|
_predictiveEcho?: PredictorStub | null;
|
||||||
|
_predictHookOnData(data: string): void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function makePredictor(): PredictorStub {
|
||||||
|
return {
|
||||||
|
predictChar: vi.fn().mockReturnValue(true),
|
||||||
|
predictBackspace: vi.fn().mockReturnValue(true),
|
||||||
|
clearPredictions: vi.fn(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const classifyPredictInput = terminalInput.classifyPredictInput as (data: string) => string;
|
||||||
|
const isCodexComposerRow = terminalInput.isCodexComposerRow as (t: unknown) => boolean;
|
||||||
|
|
||||||
|
describe('CodemanTerminalInput.classifyPredictInput', () => {
|
||||||
|
it.each([
|
||||||
|
['a', 'char'],
|
||||||
|
[' ', 'char'],
|
||||||
|
['€', 'char'],
|
||||||
|
['你', 'char'],
|
||||||
|
['😀', 'char'], // single astral codepoint
|
||||||
|
['\x7f', 'backspace'],
|
||||||
|
['\r', 'clear'],
|
||||||
|
['\n', 'clear'],
|
||||||
|
['\t', 'clear'],
|
||||||
|
['\x03', 'clear'], // Ctrl+C
|
||||||
|
['\x15', 'clear'], // Ctrl+U
|
||||||
|
['\x1b', 'clear'], // bare ESC
|
||||||
|
['\x1b[A', 'clear'], // arrow
|
||||||
|
['\x1bOA', 'clear'], // SS3 arrow
|
||||||
|
['\x1b[3~', 'clear'], // Delete
|
||||||
|
['\x1b[200~hi\x1b[201~', 'clear'], // bracketed paste
|
||||||
|
['\x1b[<0;10;5M', 'clear'], // mouse SGR report
|
||||||
|
['abc', 'text'], // plain multi-char paste
|
||||||
|
['👨👩👧', 'text'], // ZWJ emoji cluster
|
||||||
|
['\r\n', 'clear'],
|
||||||
|
])('classifies %j as %s', (data, expected) => {
|
||||||
|
expect(classifyPredictInput(data)).toBe(expected);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('CodemanTerminalInput.isCodexComposerRow', () => {
|
||||||
|
function terminalWithCursorRow(text: string | null) {
|
||||||
|
return {
|
||||||
|
buffer: {
|
||||||
|
active: {
|
||||||
|
baseY: 4,
|
||||||
|
cursorY: 2,
|
||||||
|
getLine: (y: number) => (y === 6 && text !== null ? { translateToString: () => text } : undefined),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
'› ', // empty composer
|
||||||
|
'› Use /skills to list available skills', // placeholder
|
||||||
|
'› hello', // typed text
|
||||||
|
'› /mo', // slash picker filtering
|
||||||
|
])('matches the composer row %j', (row) => {
|
||||||
|
expect(isCodexComposerRow(terminalWithCursorRow(row))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
' Press enter to continue', // trust/approval modal
|
||||||
|
' this line twice over', // wrapped continuation row (2-space indent)
|
||||||
|
'›no-space',
|
||||||
|
'1. Yes, continue',
|
||||||
|
'',
|
||||||
|
])('rejects the non-composer row %j', (row) => {
|
||||||
|
expect(isCodexComposerRow(terminalWithCursorRow(row))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads the cursor row baseY-relative (baseY + cursorY)', () => {
|
||||||
|
// terminalWithCursorRow only answers getLine(6) = baseY 4 + cursorY 2;
|
||||||
|
// a viewportY-based read would ask for a different line and get undefined
|
||||||
|
expect(isCodexComposerRow(terminalWithCursorRow('› x'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false when the row is missing or getLine throws', () => {
|
||||||
|
expect(isCodexComposerRow(terminalWithCursorRow(null))).toBe(false);
|
||||||
|
const hostile = {
|
||||||
|
buffer: {
|
||||||
|
active: {
|
||||||
|
baseY: 0,
|
||||||
|
cursorY: 0,
|
||||||
|
getLine: () => {
|
||||||
|
throw new Error('boom');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
expect(isCodexComposerRow(hostile)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('_updateLocalEchoState echo policy', () => {
|
||||||
|
it("codex + setting ON -> policy 'predict' while _localEchoEnabled stays false", () => {
|
||||||
|
const app = makeApp('codex') as PredictiveApp;
|
||||||
|
app._predictiveEcho = makePredictor();
|
||||||
|
app._updateLocalEchoState();
|
||||||
|
expect(app._localEchoPolicy).toBe('predict');
|
||||||
|
expect(app._localEchoEnabled).toBe(false); // 1.12.2 invariant untouched
|
||||||
|
expect(app._predictiveEcho.clearPredictions).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("codex + setting OFF -> policy 'off' and predictions cleared (kill switch)", () => {
|
||||||
|
const app = makeApp('codex') as PredictiveApp;
|
||||||
|
app._predictiveEcho = makePredictor();
|
||||||
|
app.loadAppSettingsFromStorage = () => ({ localEchoEnabled: false });
|
||||||
|
app._updateLocalEchoState();
|
||||||
|
expect(app._localEchoPolicy).toBe('off');
|
||||||
|
expect(app._predictiveEcho.clearPredictions).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shell -> policy 'off'", () => {
|
||||||
|
const app = makeApp('shell') as PredictiveApp;
|
||||||
|
app._predictiveEcho = makePredictor();
|
||||||
|
app._updateLocalEchoState();
|
||||||
|
expect(app._localEchoPolicy).toBe('off');
|
||||||
|
expect(app._predictiveEcho.clearPredictions).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(['claude', 'gemini', 'opencode'])("%s -> policy 'buffer' + overlay enabled (existing behavior)", (mode) => {
|
||||||
|
const overlay = makeOverlay();
|
||||||
|
const app = makeApp(mode, overlay) as PredictiveApp;
|
||||||
|
app._predictiveEcho = makePredictor();
|
||||||
|
app._updateLocalEchoState();
|
||||||
|
expect(app._localEchoPolicy).toBe('buffer');
|
||||||
|
expect(app._localEchoEnabled).toBe(true);
|
||||||
|
expect(overlay.prompts.length).toBeGreaterThan(0); // setPrompt still called
|
||||||
|
expect(app._predictiveEcho.clearPredictions).toHaveBeenCalled(); // not predict -> stray spans cleared
|
||||||
|
});
|
||||||
|
|
||||||
|
it('no active session -> policy off, no crash without a predictor instance', () => {
|
||||||
|
const app = makeApp('codex') as PredictiveApp;
|
||||||
|
app._predictiveEcho = null;
|
||||||
|
app.activeSessionId = null;
|
||||||
|
expect(() => app._updateLocalEchoState()).not.toThrow();
|
||||||
|
expect(app._localEchoPolicy).toBe('off');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('_predictHookOnData (wire neutrality)', () => {
|
||||||
|
function makePredictApp(): PredictiveApp {
|
||||||
|
const app = makeApp('codex') as PredictiveApp;
|
||||||
|
app._predictiveEcho = makePredictor();
|
||||||
|
app._updateLocalEchoState(); // -> 'predict'
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('routes char/backspace/clear kinds to the predictor', () => {
|
||||||
|
const app = makePredictApp();
|
||||||
|
app._predictHookOnData('h');
|
||||||
|
expect(app._predictiveEcho!.predictChar).toHaveBeenCalledWith('h');
|
||||||
|
app._predictHookOnData('\x7f');
|
||||||
|
expect(app._predictiveEcho!.predictBackspace).toHaveBeenCalled();
|
||||||
|
app._predictHookOnData('\r');
|
||||||
|
expect(app._predictiveEcho!.clearPredictions).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("kind 'text' (plain paste, IME commit) clears the run like 'clear'", () => {
|
||||||
|
// Review finding: an IME word-commit changes the composer without a
|
||||||
|
// prediction; new predictions after it would mis-anchor until cascade.
|
||||||
|
const app = makePredictApp();
|
||||||
|
app._predictHookOnData('pasted text');
|
||||||
|
expect(app._predictiveEcho!.predictChar).not.toHaveBeenCalled();
|
||||||
|
expect(app._predictiveEcho!.clearPredictions).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never touches _pendingInput and never sends (visual-only pin)', () => {
|
||||||
|
const app = makePredictApp();
|
||||||
|
app._pendingInput = 'queued';
|
||||||
|
for (const data of ['h', 'i', '\x7f', '\r', '\x1b[A', 'multi char', '\x1b[200~x\x1b[201~']) {
|
||||||
|
app._predictHookOnData(data);
|
||||||
|
}
|
||||||
|
expect(app._pendingInput).toBe('queued');
|
||||||
|
expect(app.sendInput).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is inert under buffer/off policies and without a predictor', () => {
|
||||||
|
const buffered = makeApp('claude') as PredictiveApp;
|
||||||
|
buffered._predictiveEcho = makePredictor();
|
||||||
|
buffered._updateLocalEchoState(); // 'buffer'
|
||||||
|
buffered._predictHookOnData('h');
|
||||||
|
expect(buffered._predictiveEcho.predictChar).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
const bundleless = makeApp('codex') as PredictiveApp;
|
||||||
|
bundleless._predictiveEcho = null;
|
||||||
|
bundleless._updateLocalEchoState();
|
||||||
|
expect(() => bundleless._predictHookOnData('h')).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a throwing predictor cannot break the hook (exception pin)', () => {
|
||||||
|
const app = makePredictApp();
|
||||||
|
app._predictiveEcho!.predictChar.mockImplementation(() => {
|
||||||
|
throw new Error('boom');
|
||||||
|
});
|
||||||
|
app._pendingInput = 'queued';
|
||||||
|
expect(() => app._predictHookOnData('h')).not.toThrow();
|
||||||
|
expect(app._pendingInput).toBe('queued');
|
||||||
|
expect(app.sendInput).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('insertTerminalText under predict policy', () => {
|
||||||
|
it('routes to sendInput (not the overlay) and clears predictions', () => {
|
||||||
|
const overlay = makeOverlay();
|
||||||
|
const app = makeApp('codex', overlay) as PredictiveApp;
|
||||||
|
app._predictiveEcho = makePredictor();
|
||||||
|
app._updateLocalEchoState(); // predict; _localEchoEnabled false
|
||||||
|
app.insertTerminalText('path.txt');
|
||||||
|
expect(app.sendInput).toHaveBeenCalledWith('path.txt');
|
||||||
|
expect(overlay.appendText).not.toHaveBeenCalled();
|
||||||
|
expect(app._predictiveEcho.clearPredictions).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -841,7 +841,7 @@ describe('Virtual Keyboard', () => {
|
|||||||
app.activeSessionId = 'mobile-visible-input-test';
|
app.activeSessionId = 'mobile-visible-input-test';
|
||||||
app.sessions.set('mobile-visible-input-test', {
|
app.sessions.set('mobile-visible-input-test', {
|
||||||
id: 'mobile-visible-input-test',
|
id: 'mobile-visible-input-test',
|
||||||
mode: 'codex',
|
mode: 'claude',
|
||||||
status: 'running',
|
status: 'running',
|
||||||
});
|
});
|
||||||
app.hideWelcome();
|
app.hideWelcome();
|
||||||
@@ -887,7 +887,7 @@ describe('Virtual Keyboard', () => {
|
|||||||
app.activeSessionId = 'mobile-cursor-fallback-test';
|
app.activeSessionId = 'mobile-cursor-fallback-test';
|
||||||
app.sessions.set('mobile-cursor-fallback-test', {
|
app.sessions.set('mobile-cursor-fallback-test', {
|
||||||
id: 'mobile-cursor-fallback-test',
|
id: 'mobile-cursor-fallback-test',
|
||||||
mode: 'codex',
|
mode: 'claude',
|
||||||
status: 'running',
|
status: 'running',
|
||||||
});
|
});
|
||||||
app.hideWelcome();
|
app.hideWelcome();
|
||||||
@@ -915,6 +915,58 @@ describe('Virtual Keyboard', () => {
|
|||||||
expect(state.overlayState?.visible).toBe(true);
|
expect(state.overlayState?.visible).toBe(true);
|
||||||
expect(state.overlayState?.promptPosition).not.toBeNull();
|
expect(state.overlayState?.promptPosition).not.toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('codex: streams keystrokes write-through and paints predictions (no buffering)', async () => {
|
||||||
|
await page.evaluate(async () => {
|
||||||
|
window.__sentInputs = [];
|
||||||
|
app.activeSessionId = 'mobile-codex-predict-test';
|
||||||
|
app.sessions.set('mobile-codex-predict-test', {
|
||||||
|
id: 'mobile-codex-predict-test',
|
||||||
|
mode: 'codex',
|
||||||
|
status: 'running',
|
||||||
|
});
|
||||||
|
app.hideWelcome();
|
||||||
|
app._sendInputAsync = (_sessionId: string, input: string) => {
|
||||||
|
window.__sentInputs.push(input);
|
||||||
|
};
|
||||||
|
const settings = app.loadAppSettingsFromStorage();
|
||||||
|
settings.cjkInputEnabled = false;
|
||||||
|
settings.localEchoEnabled = true;
|
||||||
|
app.saveAppSettingsToStorage(settings);
|
||||||
|
app._updateCjkInputState();
|
||||||
|
app._updateLocalEchoState();
|
||||||
|
// Paint a codex-like composer row so the predictWhen gate passes
|
||||||
|
app.terminal.reset();
|
||||||
|
await new Promise<void>((resolve) => app.terminal.write('\u203a ', resolve));
|
||||||
|
app.terminal.focus();
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.locator('#terminalContainer').tap({ position: { x: 40, y: 40 } });
|
||||||
|
await page.keyboard.type('hey');
|
||||||
|
|
||||||
|
// Write-through: the keystrokes reach the send path BEFORE any Enter,
|
||||||
|
// and the buffer overlay holds nothing
|
||||||
|
await page.waitForFunction(() => window.__sentInputs?.join('') === 'hey');
|
||||||
|
const typed = await page.evaluate(() => ({
|
||||||
|
policy: app._localEchoPolicy,
|
||||||
|
pendingText: app._localEchoOverlay?.pendingText ?? '',
|
||||||
|
outstanding: app._predictiveEcho?.state.outstanding ?? -1,
|
||||||
|
spans: document.querySelectorAll('.xterm-screen [data-predictive-echo] span').length,
|
||||||
|
}));
|
||||||
|
expect(typed.policy).toBe('predict');
|
||||||
|
expect(typed.pendingText).toBe('');
|
||||||
|
expect(typed.outstanding).toBeGreaterThan(0);
|
||||||
|
expect(typed.spans).toBe(typed.outstanding);
|
||||||
|
|
||||||
|
// No echo ever arrives (stubbed session): TTL self-heals within ~1s
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => document.querySelectorAll('.xterm-screen [data-predictive-echo] span').length === 0,
|
||||||
|
undefined,
|
||||||
|
{ timeout: 4000 }
|
||||||
|
);
|
||||||
|
const settled = await page.evaluate(() => app._predictiveEcho?.state.outstanding);
|
||||||
|
expect(settled).toBe(0);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Cross-device keyboard behavior ────────────────────────────────────
|
// ── Cross-device keyboard behavior ────────────────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user