Compare commits

...
Author SHA1 Message Date
arkonandClaude Opus 4.7 e8a809ea80 chore: version packages
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 03:36:56 +02:00
Ark0NandClaude Opus 4.7 8006cc5db3 fix: allowlist opusContext1mEnabled in SettingsUpdateSchema (#78)
Same root cause as the thinkingEffort fix in #73: the schema is
.strict(), so unknown keys in PUT /api/settings are rejected with
INVALID_INPUT and the toggle never persists. Verified live:
pre-fix returned {"errorCode":"INVALID_INPUT"}, post-fix accepts.

The frontend has been reading and writing this key for a while
(settings-ui.js:336, :1137; session-ui.js:331), so saves were
silently failing — users never noticed because the load path
falls back to false on missing keys.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 03:29:48 +02:00
arkonandClaude Opus 4.7 0ded279b55 chore: version packages
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 03:25:53 +02:00
Tenggan ZhangandTeigen aa5724c390 feat: improve Resume Conversation UX on mobile (#77)
Default layout was a single nowrap row with path + date + size, which on
narrow screens truncated both the first prompt and the directory suffix
(where project names actually live). The /Users/ home shorthand was also
never applied on macOS.

Changes:
- Title now uses 2-line clamp so more of the first prompt is visible.
- Subtitle resolves workingDir against known cases: exact match shows
  "#caseName", subpath shows "#caseName/sub", otherwise falls back to
  basename. Case labels are styled distinctly.
- Normalize both /home/<user>/ and /Users/<user>/ prefixes to "~/".
- Each history item gets a "..." toggle that expands an in-place detail
  panel with the full prompt, full path, timestamp, size, and short
  session id. Collapses back on second click; clicking the card body
  still triggers resume.

Co-authored-by: Teigen <teigen@TeigendeMac-mini.local>
2026-04-28 03:14:56 +02:00
Tenggan ZhangandTeigen f21df2a9fb fix: eye icon follows /clear to the new Claude conversation (#76)
Interactive Claude CLI never emits session_id on stdout, so the
Session's _claudeSessionId stayed pinned to the pre-/clear jsonl and
the last-response viewer kept showing the old conversation.

Two complementary update paths:

- Session.adoptClaudeSessionId() — public setter mirroring the existing
  no-op-if-same guard. Called from POST /api/hook-event when Claude Code
  hooks carry data.session_id (works once hooks are configured).

- /api/sessions/:id/last-response now resolves the active id from
  ~/.claude/history.jsonl before reading the transcript. This is the
  only source-of-truth that does not require hooks, and we intentionally
  don't write hooks into arbitrary user repos.

History scan filters out sessionIds held by other Codeman sessions in
the same cwd, and validates via jsonl mtime to avoid inheriting a dead
prior session's id.

Co-authored-by: Teigen <teigen@TeigendeMac-mini.local>
2026-04-28 03:03:11 +02:00
e549e15cb8 feat(response-viewer): ASCII diagram wrap toggle, mobile code blocks, chrome-stripping fallback (#75)
* fix: restore clear message separation + proper table layout in response viewer

* fix: capture Claude CLI's real session ID + robust ANSI/CLI-chrome stripping in response viewer fallback

Session constructor seeded _claudeSessionId with Codeman's session.id as a
placeholder, and the message-driven update was gated on !_claudeSessionId —
meaning Claude CLI's actual session UUID was never adopted. This broke
/api/sessions/:id/last-response JSONL lookups, silently falling through to
the terminal-buffer path whose ANSI regex missed \x1b[>c / \x1b[>q queries.

- session.ts: update _claudeSessionId whenever a message's session_id differs
  from current (covers placeholder and stale-resume cases)
- app.js: extract _cleanTerminalBuffer with proper CSI regex (param bytes
  0x30-0x3F now covers > ? < =) plus a chrome filter for status bar,
  progress bar, spinner, shell prompt, and hint lines

* fix: wrap regular code blocks on mobile, keep ASCII diagrams rigid with scroll hint

* feat: add per-block wrap toggle on ASCII-diagram code blocks

* fix: wrap by default, pin toggle button outside scroll container

* fix: narrow diagram detection to box-drawing + block elements only

* feat: show last-response viewer eye icon on desktop too

The response viewer button was mobile-only via a display:none default with a
mobile.css override. Flip the default to inline-flex and drop the override so
the eye icon appears in the header on every form factor — desktop users get
the same quick "Last Response" pane as mobile.

* fix(response-viewer): restore HTML sanitizer + fix undefined `src` in _renderMarkdown

- `_renderMarkdown` referenced an undefined `src` (should be `text`),
  causing a ReferenceError on every markdown render. The try/catch
  swallowed it, so the new table-wrap and ASCII-diagram features
  never actually ran — output silently fell through to plain-text.
  app.js is excluded from ESLint, so this wasn't caught at lint time.
- `_sanitizeHtml` was removed when refactoring the response viewer,
  leaving `marked.parse()` output going straight into `innerHTML`
  without sanitization (XSS regression vs. master). Restored the
  helper and re-applied it before any post-processing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Teigen <teigen@TeigendeMac-mini.local>
Co-authored-by: arkon <arkon.85@hotmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 02:58:42 +02:00
Ark0N d07b59db4e Merge pull request #74 from TeigenZhang/refactor/envoverrides-tmux-export
refactor: pass envOverrides via tmux export instead of disk write
2026-04-28 02:45:11 +02:00
arkonandClaude Opus 4.7 a5a7e0c94c Merge master into refactor/envoverrides-tmux-export
Resolved conflict in src/web/public/session-ui.js by keeping this
PR's buildEnvOverrides() helper — it already covers both
CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS (this PR) and
CLAUDE_CODE_EFFORT_LEVEL (added in #73), so the master-side inline
block is fully replaced.

Also fixed test/session-manager.test.ts MockSession to add a
getEnvOverridesForPersist() stub — without it,
SessionManager.updateSessionState's new call breaks 19 tests with
"TypeError: session.getEnvOverridesForPersist is not a function".

Verified: typecheck, lint, format:check, build, and
test/{session-manager,session-state,tmux-manager,tmux-restart-recovery}.test.ts
all pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 02:43:31 +02:00
Ark0N 79d7117e6d Merge pull request #73 from TeigenZhang/feat/thinking-effort
feat: thinking effort setting for new sessions (with xhigh/max)
2026-04-28 02:21:57 +02:00
arkonandClaude Opus 4.7 3cf486730b fix: allowlist thinkingEffort in SettingsUpdateSchema
Without this, PUT /api/settings rejects the new field with
INVALID_INPUT (schema is .strict()), so the dropdown's value
never persists.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 02:20:11 +02:00
Tenggan Zhang 996b096849 fix: prevent vertical scroll on mobile keyboard accessory bar (#72)
Thank you @TeigenZhang for the clean mobile fix!
2026-04-28 02:15:00 +02:00
ffa7fcf839 fix(tmux-manager): use '|' separator in reconcileSessions (#71)
* fix(tmux-manager): use '|' separator in reconcileSessions

Under non-tty execution contexts (launchd on macOS, systemd without TTY),
tmux emits '\t' in FORMAT strings as the literal two characters `\` + `t`
rather than as a tab. The parser's `line.indexOf('\t')` (a real tab char)
therefore never matches, `activeSessions` stays empty, `reconcileSessions`
returns `alive: []` / `discovered: []`, and `cleanupStaleSessions()` wipes
every entry in `state.json` — even though the underlying tmux sessions are
still alive. On the next startup the user sees an empty session list.

The bug reproduces reliably when codeman is launched via a user LaunchAgent
or a systemd unit without `TTYPath`. Interactive `npm run dev` hides it
because tmux's format parser does interpret `\t` when stdout is a TTY.

Fix: use `|` as the separator. tmux passes it through verbatim in every
environment, and `|` is not a valid tmux session-name character so it
cannot collide with the codeman-<uuid> / claudeman-<uuid> naming scheme.

* test(tmux-manager): cover parsePaneList separator contract

Extract the inline pane-list parser from `reconcileSessions` into an
exported `parsePaneList()` helper plus `PANE_LIST_SEP` / `PANE_LIST_FORMAT`
constants, so the '|' separator contract can be unit-tested directly.

The new tests lock in:
- Well-formed parsing into name -> pid Map
- Empty / blank-line / missing-separator handling
- Non-numeric pid and empty-name rejection
- A literal `\t` (backslash + t) in the input is NOT treated as a
  delimiter — guards against the launchd/systemd regression that
  motivated PR #71.
- Splitting on the first separator only.

No behavior change in `reconcileSessions`; the body now delegates to the
helper.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Teigen <teigen@TeigendeMac-mini.local>
Co-authored-by: arkon <arkon.85@hotmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 02:11:11 +02:00
Teigen a1c69f7405 refactor: pass envOverrides via tmux export instead of disk write
CLAUDE_CODE_EFFORT_LEVEL (and any CLAUDE_CODE_* / OPENCODE_* key) now flows:
  UI dropdown → POST /api/sessions { envOverrides }
             → new Session({ envOverrides })
             → this._envOverrides
             → tmux-manager.buildEnvExports appends `export KEY=<shellescape(VALUE)>`

Previously the API wrote envOverrides to <case>/.claude/settings.local.json, which
created stale state (UI dropdown disagreeing with disk) and polluted user project
directories. Now envOverrides are ephemeral spawn-time state, preserved across
respawnPane cycles via this._envOverrides and across server restart via
SessionState.envOverrides in state.json.

Also removes the now-unused updateCaseEnvVars import from session-routes.ts.
2026-04-24 09:49:52 +08:00
Teigen 534899bc2b feat: add xhigh effort option and /effort max mobile shortcut
Add XHigh option to Thinking Effort dropdown (between High and Max),
and add a Max quick button to the mobile keyboard accessory bar that
sends /effort max as a slash command.
2026-04-24 09:48:53 +08:00
Teigen 03d91ffddd feat: add thinking effort setting for new sessions
Allow configuring CLAUDE_CODE_EFFORT_LEVEL (low/medium/high/max) from
Settings → Claude Permissions. Applied as envOverride on session creation.
2026-04-24 09:48:18 +08:00
arkonandClaude Opus 4.7 6280998bd8 chore: version packages
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 20:22:30 +02:00
arkonandClaude Opus 4.7 02e2f3e8b5 refactor: remove dead code and narrow internal exports (knip sweep)
Knip-driven cleanup. All changes verified with tsc --noEmit, lint, and
build.

Removed (zero consumers):
- VERIFICATION_PROMPT constant + its barrel re-export
- createInitialOrchestratorPersistState factory
- transcriptWatcher singleton export
- createAnsiPatternFull / createAnsiPatternSimple factories
- TimerInfo interface + unused AiCheckResult/AiPlanCheckResult imports
  in respawn-controller.ts
- 35 unused Zod z.infer \`*Input\` types in schemas.ts
- Dead re-exports: SessionMode from session.ts, AuthSessionRecord from
  web/ports/index.ts, EnhancedPlanTask/CheckpointReview from
  ralph-tracker.ts, 7 unused entries in utils/index.ts
- 14 event/config interfaces that lived only as JSDoc hints (no TS type
  position usage): Session/Respawn/RalphLoop/RalphTracker/
  SessionManager/SessionAutoOps/Subagent/TaskQueue/TaskTracker/
  TranscriptWatcher/Image/OrchestratorLoop Events + RespawnPreset +
  SessionOutput

Narrowed to module scope (kept but no longer exported):
- buildPermissionArgs in session-cli-builder.ts
- 28 type/interface declarations used only within their own file:
  Ai{Idle,Plan}Check{Config,State}, BashToolParser{Events,Config},
  FileStream/CreateStream{Options,Result}, PlanSubagentEvent,
  SubagentCallback, RalphLoopConfig, RalphLoop{Events,Options},
  ActiveTimerInfo, DetectionStatus, ActionLogEntry, AutoOpsCallbacks,
  TunnelStatus, Timer/LRUMap/StaleExpirationMap Options, AuthState,
  SessionListenerDeps, SseStreamManagerDeps, and 8 more

Docs: CLAUDE.md advice for global-regex `lastIndex` now points to the
remaining `execPattern()` helper instead of the deleted factories.

Knip delta: unused files 42→0, unused exports 161→16, unused types 92→0.
The 16 remaining exports are a mobile-test helper toolkit intentionally
kept for upcoming tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 11:57:00 +02:00
arkonandClaude Opus 4.7 41300f0a34 chore: add knip config for dead-code detection
knip.json declares the real entry points (scripts, tests, Remotion roots)
and the devDeps invoked only as external CLIs (esbuild for build,
agent-browser/remotion via npx) so future scans surface only true
findings.

Add \`npm run knip\` as the canonical invocation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 11:56:31 +02:00
arkonandClaude Opus 4.7 adbc083426 chore: remove dead test and script files
Dead-code sweep via knip. All files below have zero importers and were
leftovers from the local-echo overlay exploration or duplicated by files
under test/mocks/.

Deleted:
- test/respawn-test-utils.ts (728-line duplicate of test/mocks/*)
- test/input-echo-test.mjs
- test/local-echo-*.mjs (7 files)
- test/manual/*.mjs (10 files; dir removed)
- scripts/remotion/components/TerminalScreen.tsx (unused Remotion demo)

Also cleaned stale JSDoc references to the removed
respawn-test-utils.ts in test/mocks/mock-session.ts and
test/mocks/test-helpers.ts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 11:56:22 +02:00
arkonandClaude Opus 4.7 3754bcd1aa docs: tighten CLAUDE.md — update counts and CI line
- CI line: include `check:lockfile` step now run in workflow
- Frontend: app.js is ~2.9K lines (was ~2.8K)
- Types: document `src/types/index.ts` as the barrel (14 domain files) plus `src/types.ts` root re-export
- API routes: updated handler counts (128 total; sessions 27, cases 9)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 11:55:56 +02:00
arkonandClaude Opus 4.7 f2f909ca9c docs: tighten CLAUDE.md — fix counts, remove footer redundancy
Fix stale counts (types 14 to 15, SSE events ~118 to ~120). Remove
redundant footer sections (References list duplicated inline citations;
Common Workflows bullets were self-evident or already stated; Tunnel and
Memory Leak Prevention folded into neighboring sections). 251 to 234 lines.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 11:14:16 +02:00
arkonandClaude Opus 4.7 1c3f2f6571 docs: tighten CLAUDE.md and archive 22 completed plan docs
CLAUDE.md: fix stale counts (types 14 to 15, SSE events ~118 to ~120),
remove redundant footer sections (References list duplicated inline citations;
Common Workflows bullets were self-evident or already stated; Tunnel/Memory
Leak Prevention folded into neighboring sections). 251 to 234 lines.

Move 22 completed implementation/phase/audit plans to docs/archive/ via
git mv so history is preserved. Living reference docs remain in docs/.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 11:14:07 +02:00
arkonandClaude Opus 4.7 8da1bdf690 chore: prevent package-lock.json version drift permanently
Makes the drift that PR #70 caught impossible to repeat:

- `version-packages` script now runs `changeset version && npm install
  --package-lock-only && check-lockfile-sync`, so the lockfile is always
  regenerated and verified as part of consuming a changeset
- New `scripts/check-lockfile-sync.mjs` compares package.json#.version against
  package-lock.json's root and packages[""] version fields (npm ci does not
  enforce these, which is why the prior drift slipped through CI)
- CI now runs `npm run check:lockfile` on every push/PR — any future drift
  fails the build before merge
- COM workflow in CLAUDE.md collapsed back to a single release-bump step now
  that lockfile sync is automatic

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 11:01:51 +02:00
arkonandClaude Opus 4.7 a93325b312 fix: sync package-lock.json to 0.6.0 and document lockfile step in COM workflow
package.json has been at 0.6.0 since release, but package-lock.json stayed at
0.3.11 because `npm run version-packages` (changesets) does not regenerate the
lockfile. This left `npm ci` broken against the committed state.

Also adds step 4 (`npm install --package-lock-only`) to the COM workflow in
CLAUDE.md so future releases keep the lockfile in sync automatically.

Credit to @Matt2012 (#70) for catching the lockfile drift.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 11:00:10 +02:00
arkonandClaude Opus 4.7 2d03e4efc9 docs: update CLAUDE.md and README for clipboard API and tab shortcuts
Reflect changes from PRs #65–#68: bumped route/SSE counts, added
Ctrl+Shift+{/} (tab reorder), Alt+1-9 (tab switch), Ctrl+Shift+V
(voice), and POST /api/clipboard to the keyboard and API references.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 04:16:36 +02:00
105 changed files with 1225 additions and 6735 deletions
+3
View File
@@ -22,6 +22,9 @@ jobs:
- name: Install dependencies
run: npm ci
- name: Check package-lock.json version sync
run: npm run check:lockfile
- name: Type check
run: npm run typecheck
+37
View File
@@ -1,5 +1,42 @@
# aicodeman
## 0.6.3
### Patch Changes
- **Fix**
- Allowlist `opusContext1mEnabled` in `SettingsUpdateSchema`. Without this entry, the strict schema rejected `PUT /api/settings {"opusContext1mEnabled":...}` with `INVALID_INPUT`, so the toggle's value never persisted across reloads. The frontend was already reading and writing this key (`settings-ui.js:336/1137`, `session-ui.js:340`), so saves were silently failing — users never noticed because the load path falls back to `false` on missing keys, hiding the bug. (#78)
## 0.6.2
### Patch Changes
- **Mobile UX**
- Resume Conversation list (welcome page) reworked for narrow screens: 2-line title clamp so more of the first prompt is visible; case-aware subtitle that renders `#caseName` (or `#caseName/sub`) when `workingDir` matches a known case, otherwise falls back to the directory basename; inline `⋯` toggle that expands a detail panel with full prompt, full path, timestamp, size, and short session id; `/Users/<user>/` now collapses to `~/` alongside `/home/<user>/`. (#77)
- Response viewer: ASCII diagram wrap toggle, dedicated mobile code-block layout, and chrome-stripping fallback when the model wraps its reply in extra markup. (#75)
- Mobile keyboard accessory bar no longer triggers vertical scroll. (#72)
**Sessions & settings**
- New `thinkingEffort` setting on session creation, with `xhigh` option and `/effort max` mobile shortcut. (#73)
- `thinkingEffort` is now allowlisted in `SettingsUpdateSchema` so it round-trips through PATCH /api/settings.
- `envOverrides` (`CLAUDE_CODE_*` / `OPENCODE_*`) are now passed to Claude via tmux env exports at spawn time instead of being written to `<case>/.claude/settings.local.json`. Eliminates UI/disk drift; the value lives on `Session._envOverrides`, is exported by `tmux-manager.buildEnvExports()`, and is persisted in `SessionState.envOverrides`. (#74)
**Fixes**
- Eye icon (active-session indicator) now follows `/clear` to the new Claude conversation instead of getting stuck on the previous transcript. (#76)
- `tmux-manager.reconcileSessions` now uses `|` as the field separator, fixing parsing when session names contain other delimiters. (#71)
**Docs**
- CLAUDE.md: added `npm run knip` to the dead-code sweep table and a `Common Gotchas` entry documenting the `envOverrides` → tmux export flow.
## 0.6.1
### Patch Changes
- Internal cleanup and release hygiene:
- **Dead-code sweep via knip**: added `knip.json` for dead-code detection and ran a full sweep — removed unused test files, unused scripts, and narrowed internal module exports to the minimum surface area actually consumed.
- **Lockfile drift prevention**: `version-packages` now runs `npm install --package-lock-only` and verifies the lockfile is in sync via `scripts/check-lockfile-sync.mjs`; CI runs the same check on every push/PR so version drift fails the build instead of reaching production. Resolves the `package-lock.json` / `package.json` version mismatch that shipped in 0.6.0.
- **Docs tightening**: archived 22 completed plan docs from `docs/`, corrected file/handler counts in `CLAUDE.md`, documented the lockfile step in the COM workflow, and removed footer redundancy.
## 0.6.0
### Minor Changes
+27 -37
View File
@@ -10,7 +10,8 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
| Type check | `tsc --noEmit` |
| Lint | `npm run lint` (fix: `npm run lint:fix`) |
| Format | `npm run format` (check: `npm run format:check`) |
| Single test | `npx vitest run test/<file>.test.ts` |
| Single test | `npm test -- test/<file>.test.ts` (or `npx vitest run --config config/vitest.config.ts test/<file>.test.ts`) |
| Build | `npm run build` (esbuild via `scripts/build.mjs`, NOT tsc — `tsc --noEmit` is type-check only) |
| Production | `npm run build && systemctl --user restart codeman-web` |
## CRITICAL: Session Safety
@@ -48,11 +49,13 @@ When user says "COM":
CHANGESET
```
Replace `patch` with `minor` or `major` as needed. Include `"xterm-zerolag-input": patch` on a separate line if that package changed too.
3. **Consume the changeset**: `npm run version-packages` (bumps versions in `package.json` files and updates `CHANGELOG.md`)
3. **Consume the changeset**: `npm run version-packages` (auto-bumps `package.json` files, updates `CHANGELOG.md`, runs `npm install --package-lock-only`, and verifies lockfile sync via `scripts/check-lockfile-sync.mjs` — all in one command; never hand-edit `CHANGELOG.md` or `package-lock.json` versions)
4. **Sync CLAUDE.md version**: Update the `**Version**` line below to match the new version from `package.json`
5. **Commit and deploy**: `git add -A && git commit -m "chore: version packages" && git push && npm run build && systemctl --user restart codeman-web`
**Version**: 0.6.0 (must match `package.json`)
CI runs `npm run check:lockfile` on every push/PR, so lockfile drift fails the build even if the `version-packages` script is bypassed.
**Version**: 0.6.3 (must match `package.json`)
## Project Overview
@@ -75,10 +78,11 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
| Dev with TLS | `npx tsx src/index.ts web --https` |
| Continuous typecheck | `tsc --noEmit --watch` |
| Test coverage | `npm run test:coverage` |
| Dead-code sweep | `npm run knip` (config in `knip.json`) |
| Production start | `npm run start` |
| Production logs | `journalctl --user -u codeman-web -f` |
**CI**: `.github/workflows/ci.yml` runs `typecheck`, `lint`, `format:check` on push to master/main and on PRs (Node 22). Tests excluded (they spawn tmux).
**CI**: `.github/workflows/ci.yml` runs `check:lockfile`, `typecheck`, `lint`, `format:check` on push to master/main and on PRs (Node 22). Tests excluded (they spawn tmux).
**Code style**: Prettier (`singleQuote: true`, `printWidth: 120`, `trailingComma: "es5"`). ESLint flat config (`config/eslint.config.js`) allows `no-console`, warns on `@typescript-eslint/no-explicit-any`. Ignores: `app.js`, `scripts/**/*.mjs`, `src/web/public/vendor/**`, `scripts/remotion/**`.
@@ -87,7 +91,8 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
- **Single-line prompts only** — `writeViaMux()` sends text+Enter separately; multi-line breaks Ink
- **ESM only** — Never `require()`, use `await import()`. `tsx` masks CJS/ESM issues in dev but production breaks
- **Package ≠ product name** — npm: `aicodeman`, product: **Codeman**. Release renames tags accordingly
- **Global regex `lastIndex`** — Use `createAnsiPatternFull/Simple()` factories, not shared `g`-flag patterns in loops
- **Global regex `lastIndex`** — Shared `g`-flag patterns in loops must reset `lastIndex = 0` first, or use the `execPattern()` helper in `utils/regex-patterns.ts` (resets automatically)
- **`envOverrides` flow `CLAUDE_CODE_*` / `OPENCODE_*` env vars** — Set via `POST /api/sessions { envOverrides }`, stored on `Session._envOverrides`, exported by `tmux-manager.buildEnvExports()` at spawn time, persisted in `SessionState.envOverrides`. **Do NOT** write these to `<case>/.claude/settings.local.json` — that's the old path and creates UI/disk drift
**Import conventions**: Utils from `./utils`, types from `./types` (barrel), config from specific `./config/*` files.
@@ -109,11 +114,11 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
| **State** | `src/state-store.ts`, `src/run-summary.ts`, `src/session-lifecycle-log.ts` | |
| **Infra** | `src/hooks-config.ts`, `src/push-store.ts`, `src/tunnel-manager.ts`, `src/image-watcher.ts`, `src/file-stream-manager.ts` | |
| **Plan** | `src/plan-orchestrator.ts`, `src/prompts/*.ts`, `src/templates/claude-md.ts` | |
| **Web** | `src/web/server.ts`, `src/web/sse-events.ts`, `src/web/routes/*.ts` (14 route modules + barrel), `src/web/route-helpers.ts`, `src/web/ports/*.ts`, `src/web/middleware/auth.ts`, `src/web/schemas.ts` | |
| **Frontend** | `src/web/public/app.js` (~2.8K lines, core) + 5 infra modules (`constants.js`, `mobile-handlers.js`, `voice-input.js`, `notification-manager.js`, `keyboard-accessory.js`) + 7 domain modules (`terminal-ui.js`, `respawn-ui.js`, `ralph-panel.js`, `orchestrator-panel.js`, `settings-ui.js`, `panels-ui.js`, `session-ui.js`) + 4 feature modules (`ralph-wizard.js`, `api-client.js`, `subagent-windows.js`, `input-cjk.js`) + `sw.js` | |
| **Types** | `src/types/index.ts` → 14 domain files | See `@fileoverview` in index.ts |
| **Web** | `src/web/server.ts`, `src/web/sse-events.ts`, `src/web/routes/*.ts` (15 route modules + barrel), `src/web/route-helpers.ts`, `src/web/ports/*.ts`, `src/web/middleware/auth.ts`, `src/web/schemas.ts` | |
| **Frontend** | `src/web/public/app.js` (~2.9K lines, core) + 5 infra modules (`constants.js`, `mobile-handlers.js`, `voice-input.js`, `notification-manager.js`, `keyboard-accessory.js`) + 7 domain modules (`terminal-ui.js`, `respawn-ui.js`, `ralph-panel.js`, `orchestrator-panel.js`, `settings-ui.js`, `panels-ui.js`, `session-ui.js`) + 4 feature modules (`ralph-wizard.js`, `api-client.js`, `subagent-windows.js`, `input-cjk.js`) + `sw.js` | |
| **Types** | `src/types/index.ts` (barrel) → 14 domain files; also `src/types.ts` root re-export | See `@fileoverview` in index.ts |
★ = Large file (>50KB). All files have `@fileoverview` JSDoc — read that before diving in.
★ = Large file (>50KB). All files have `@fileoverview` JSDoc — read that before diving in. Discovery aid: `grep -l '@fileoverview' src/web/routes/*.ts` lists all route modules; same grep works for `src/types/`, `src/web/public/*.js`.
**Local package**: `packages/xterm-zerolag-input/` — local echo overlay for xterm.js; copy embedded in `app.js`.
@@ -150,7 +155,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
**Respawn presets**: `solo-work` (3s/60min), `subagent-workflow` (45s/240min), `team-lead` (90s/480min), `ralph-todo` (8s/480min), `overnight-autonomous` (10s/480min).
**Keyboard shortcuts**: Escape (close), Ctrl+? (help), Ctrl+W (kill), Ctrl+Tab (next), Alt+1-9 (switch tab), Shift+Enter (newline), Ctrl+L (clear), Ctrl+Shift+R (restore size), Ctrl/Cmd +/- (font).
**Keyboard shortcuts**: Escape (close), Ctrl+? (help), Ctrl+W (kill), Ctrl+Tab (next), Alt+1-9 (switch tab), Ctrl+Shift+{/} (move tab left/right), Shift+Enter (newline), Ctrl+L (clear), Ctrl+Shift+R (restore size), Ctrl+Shift+V (voice input), Ctrl/Cmd +/- (font).
### Security
@@ -167,11 +172,11 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
### SSE Event Registry
~117 event types in `src/web/sse-events.ts` (backend) and `SSE_EVENTS` in `constants.js` (frontend). Both must be kept in sync.
~120 event types in `src/web/sse-events.ts` (backend) and `SSE_EVENTS` in `constants.js` (frontend). Both must be kept in sync.
### API Routes
~124 handlers across 14 route files in `src/web/routes/`: system (36), sessions (25), orchestrator (10), ralph (9), plan (8), respawn (7), cases (7), files (5), mux (5), scheduled (4), push (4), teams (2), hooks (1), ws (1 WebSocket). Each file has `@fileoverview` with endpoint details.
~128 handlers across 15 route files in `src/web/routes/`: system (36), sessions (27), orchestrator (10), cases (9), ralph (9), plan (8), respawn (7), files (5), mux (5), push (4), scheduled (4), teams (2), hooks (1), clipboard (1), ws (1 WebSocket). Each file has `@fileoverview` with endpoint details.
## Adding Features
@@ -193,11 +198,13 @@ All in `~/.codeman/`: `state.json` (sessions, settings, respawn), `mux-sessions.
**CRITICAL: You are running inside a Codeman-managed tmux session.** Never run `npx vitest run` (full suite) — it spawns/kills tmux sessions and will crash your own session. Only run individual files:
```bash
npx vitest run test/<specific-file>.test.ts # Single file (SAFE)
npx vitest run -t "pattern" # By name (SAFE)
# npx vitest run # DANGEROUS — DON'T DO THIS
npm test -- test/<specific-file>.test.ts # Single file (SAFE, uses config/vitest.config.ts)
npm test -- -t "pattern" # By name (SAFE)
# npm test # DANGEROUS — runs full suite, DON'T DO THIS
```
Raw `npx vitest` skips `config/vitest.config.ts`; always use `npm test --` or pass `--config config/vitest.config.ts`.
**Config**: Vitest with `globals: true`, `fileParallelism: false`. Timeout 30s, teardown 60s.
**Safety**: `test/setup.ts` snapshots pre-existing tmux sessions and never kills them. Only `registerTestTmuxSession()` sessions get cleaned up.
@@ -206,10 +213,6 @@ npx vitest run -t "pattern" # By name (SAFE)
**Respawn tests**: Use `MockSession` from `test/respawn-test-utils.ts`. **Route tests**: `app.inject()` in `test/routes/`. **Mobile tests**: Playwright suite in `test/mobile/` (135 device profiles).
## Screenshots
Mobile screenshots in `~/.codeman/screenshots/`. API: `GET /api/screenshots`, `POST /api/screenshots`.
## Debugging
```bash
@@ -220,27 +223,14 @@ curl localhost:3000/api/subagents | jq # Background agents
cat ~/.codeman/state.json | jq # Persisted state
```
Mobile screenshots: `~/.codeman/screenshots/`, accessed via `GET/POST /api/screenshots`.
## Performance & Limits
Target: 20 sessions, 50 agent windows at 60fps. Limits in `src/config/`: terminal 2MB, text 1MB, messages 1000, max agents 500, max sessions 50, max SSE clients 100. Use `LRUMap` for bounded caches, `StaleExpirationMap` for TTL cleanup. Anti-flicker pipeline: `docs/terminal-anti-flicker.md`.
## References
**Memory leaks (24+ hour sessions)**: use `CleanupManager`, clear Maps in `stop()`, guard async with `if (this.cleanup.isStopped) return`. Frontend: store handler refs, clean in `close*()`. Verify: `npm test -- test/memory-leak-prevention.test.ts`.
Deep-dive docs in `docs/`: `respawn-state-machine.md`, `ralph-wiggum-guide.md`, `claude-code-hooks-reference.md`, `terminal-anti-flicker.md`, `opencode-integration.md`, `qr-auth-plan.md`, `orchestrator-loop-architecture.md`, `browser-testing-guide.md`. Agent Teams: `docs/agent-teams/README.md`. SSE events: `src/web/sse-events.ts` + `constants.js`.
## Scripts & Tunnel
## Scripts
Key: `scripts/tmux-manager.sh` (safe tmux mgmt), `scripts/tunnel.sh` (tunnel start/stop/url). Production: `scripts/codeman-web.service`, `scripts/codeman-tunnel.service`.
## Memory Leak Prevention
24+ hour sessions: use `CleanupManager`, clear Maps in `stop()`, guard async with `if (this.cleanup.isStopped) return`. Frontend: store handler refs, clean in `close*()`. Verify: `npx vitest run test/memory-leak-prevention.test.ts`.
## Common Workflows
**Bug investigation**: Dev server → reproduce in browser → check terminal + `~/.codeman/state.json`.
**Respawn changes**: Read `docs/respawn-state-machine.md` first. Use `MockSession` from `test/respawn-test-utils.ts`.
## Tunnel
`./scripts/tunnel.sh start|stop|url`. **Always set `CODEMAN_PASSWORD`** before exposing via tunnel.
Key scripts: `scripts/tmux-manager.sh` (safe tmux mgmt), `scripts/tunnel.sh start|stop|url` (tunnel). Production services: `scripts/codeman-web.service`, `scripts/codeman-tunnel.service`. **Always set `CODEMAN_PASSWORD`** before exposing via tunnel.
+4
View File
@@ -401,9 +401,12 @@ Single-digit selection (1-9), color-coded status, token counts, auto-refresh. De
| `Ctrl+Enter` | Quick-start session |
| `Ctrl+W` | Close session |
| `Ctrl+Tab` | Next session |
| `Alt+1`–`Alt+9` | Switch to tab N |
| `Ctrl+Shift+{` / `Ctrl+Shift+}` | Move active tab left / right |
| `Ctrl+K` | Kill all sessions |
| `Ctrl+L` | Clear terminal |
| `Ctrl+Shift+R` | Restore terminal size |
| `Ctrl+Shift+V` | Toggle voice input |
| `Ctrl/Cmd +/-` | Font size |
| `Escape` | Close panels |
@@ -446,6 +449,7 @@ Single-digit selection (1-9), color-coded status, token counts, auto-refresh. De
| `GET` | `/api/events` | SSE stream |
| `GET` | `/api/status` | Full app state |
| `POST` | `/api/hook-event` | Hook callbacks |
| `POST` | `/api/clipboard` | Push text to all connected browsers (`{text}`) |
| `GET` | `/api/sessions/:id/run-summary` | Timeline + stats |
---
+16
View File
@@ -0,0 +1,16 @@
{
"$schema": "https://unpkg.com/knip@5/schema.json",
"entry": [
"scripts/*.mjs",
"scripts/*.js",
"scripts/watch-subagents.ts",
"scripts/remotion/Root.tsx",
"scripts/remotion/index.ts",
"test/**/*.test.ts",
"test/mobile/vitest.config.ts",
"test/**/*.mjs"
],
"project": ["src/**/*.{ts,tsx}", "scripts/**/*.{ts,tsx,mjs,js}", "test/**/*.{ts,mjs}"],
"ignoreExportsUsedInFile": true,
"ignoreDependencies": ["@remotion/cli", "@remotion/transitions", "esbuild", "agent-browser"]
}
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "aicodeman",
"version": "0.3.11",
"version": "0.6.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "aicodeman",
"version": "0.3.11",
"version": "0.6.3",
"hasInstallScript": true,
"license": "MIT",
"workspaces": [
+4 -2
View File
@@ -1,6 +1,6 @@
{
"name": "aicodeman",
"version": "0.6.0",
"version": "0.6.3",
"description": "The missing control plane for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
"type": "module",
"main": "dist/index.js",
@@ -25,7 +25,9 @@
"format:check": "prettier --check 'src/**/*.ts'",
"capture:subagents": "node scripts/capture-subagent-screenshots.mjs",
"changeset": "changeset",
"version-packages": "changeset version",
"version-packages": "changeset version && npm install --package-lock-only && node scripts/check-lockfile-sync.mjs",
"check:lockfile": "node scripts/check-lockfile-sync.mjs",
"knip": "npx --yes knip@latest",
"release": "changeset publish"
},
"workspaces": [
+29
View File
@@ -0,0 +1,29 @@
#!/usr/bin/env node
// Fails if package-lock.json's version fields don't match package.json.
// Changesets bumps package.json but NOT the lockfile — this catches that drift
// (the top-level `version` in lockfiles is metadata, so `npm ci` won't flag it).
import { readFileSync } from 'node:fs';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const pkg = JSON.parse(readFileSync(resolve(repoRoot, 'package.json'), 'utf8'));
const lock = JSON.parse(readFileSync(resolve(repoRoot, 'package-lock.json'), 'utf8'));
const expected = pkg.version;
const rootVersion = lock.version;
const selfVersion = lock.packages?.['']?.version;
const mismatches = [];
if (rootVersion !== expected) mismatches.push(` package-lock.json#.version = ${rootVersion} (expected ${expected})`);
if (selfVersion !== expected) mismatches.push(` package-lock.json#.packages[""].version = ${selfVersion} (expected ${expected})`);
if (mismatches.length > 0) {
console.error(`\nLockfile version drift detected (package.json is ${expected}):`);
console.error(mismatches.join('\n'));
console.error('\nFix: run `npm install --package-lock-only` and commit the updated package-lock.json.\n');
process.exit(1);
}
console.log(`Lockfile in sync with package.json (${expected}).`);
@@ -1,117 +0,0 @@
import React from 'react';
import { useCurrentFrame } from 'remotion';
import { colors, fonts } from '../lib/theme';
type OverlayChar = {
char: string;
confirmed: boolean;
};
type TerminalScreenProps = {
typed: string;
cursorVisible: boolean;
overlayChars?: OverlayChar[];
fontSize?: number;
};
/**
* Renders a terminal area styled exactly like the real Codeman xterm.js terminal.
* Background #0d0d0d, Fira Code font, block cursor, Claude Code prompt.
*/
export const TerminalScreen: React.FC<TerminalScreenProps> = ({
typed,
cursorVisible,
overlayChars,
fontSize = 28,
}) => {
const frame = useCurrentFrame();
// Block cursor — solid, no blink (matches Codeman's cursorBlink: false)
const cursorOn = cursorVisible;
// But add a subtle blink for video clarity so viewers notice it
const cursorOpacity = cursorOn ? (Math.floor(frame / 20) % 2 === 0 ? 0.9 : 0.6) : 0;
const lineHeight = Math.round(fontSize * 1.35);
const charWidth = fontSize * 0.6;
return (
<div
style={{
width: '100%',
height: '100%',
background: '#0d0d0d',
position: 'relative',
fontFamily: '"Fira Code", "Cascadia Code", "JetBrains Mono", "SF Mono", Monaco, monospace',
overflow: 'hidden',
}}
>
{/* Previous terminal output lines (fake history for realism) */}
<div
style={{
padding: '16px 20px',
fontSize: fontSize * 0.65,
lineHeight: `${Math.round(fontSize * 0.65 * 1.4)}px`,
color: '#495057',
}}
>
<div>
<span style={{ color: '#339af0' }}>❯</span>
<span style={{ color: '#495057' }}> claude --dangerously-skip-permissions</span>
</div>
<div style={{ color: '#3a3a3a', marginTop: 4 }}>
╭────────────────────────────────────╮
</div>
<div style={{ color: '#3a3a3a' }}>
│ Claude Code session active │
</div>
<div style={{ color: '#3a3a3a' }}>
╰────────────────────────────────────╯
</div>
</div>
{/* Active prompt line — this is where the typing happens */}
<div
style={{
padding: '8px 20px',
fontSize,
lineHeight: `${lineHeight}px`,
display: 'flex',
alignItems: 'baseline',
}}
>
{/* Prompt character */}
<span style={{ color: '#339af0', fontWeight: 700, marginRight: charWidth * 0.8 }}>❯</span>
{/* Typed text */}
{overlayChars ? (
// Zerolag mode: show overlay chars with confirmed/unconfirmed color
overlayChars.map((oc, i) => (
<span
key={i}
style={{
color: oc.confirmed ? '#e0e0e0' : '#7a7a7a',
letterSpacing: '0.5px',
}}
>
{oc.char}
</span>
))
) : (
<span style={{ color: '#e0e0e0', letterSpacing: '0.5px' }}>{typed}</span>
)}
{/* Block cursor */}
<span
style={{
display: 'inline-block',
width: charWidth,
height: lineHeight * 0.85,
background: `rgba(224, 224, 224, ${cursorOpacity})`,
verticalAlign: 'text-bottom',
marginLeft: 1,
}}
/>
</div>
</div>
);
};
+1 -1
View File
@@ -41,7 +41,7 @@ import {
// ========== Types ==========
export type AiIdleCheckConfig = AiCheckerConfigBase;
type AiIdleCheckConfig = AiCheckerConfigBase;
export type AiCheckVerdict = 'IDLE' | 'WORKING' | 'ERROR';
+2 -2
View File
@@ -40,13 +40,13 @@ import {
// ========== Types ==========
export type AiPlanCheckConfig = AiCheckerConfigBase;
type AiPlanCheckConfig = AiCheckerConfigBase;
export type AiPlanCheckVerdict = 'PLAN_MODE' | 'NOT_PLAN_MODE' | 'ERROR';
export type AiPlanCheckResult = AiCheckerResultBase<AiPlanCheckVerdict>;
export type AiPlanCheckState = AiCheckerStateBase<AiPlanCheckVerdict>;
type AiPlanCheckState = AiCheckerStateBase<AiPlanCheckVerdict>;
// ========== Constants ==========
+2 -2
View File
@@ -99,7 +99,7 @@ const LOG_FILE_MENTION_PATTERN = /([/~][^\s'"<>|;&\n]*(?:\.log|\.txt|\.out|\/log
/**
* Events emitted by BashToolParser.
*/
export interface BashToolParserEvents {
interface BashToolParserEvents {
/** New Bash tool with file paths started */
toolStart: [tool: ActiveBashTool];
/** Bash tool completed */
@@ -111,7 +111,7 @@ export interface BashToolParserEvents {
/**
* Configuration options for BashToolParser.
*/
export interface BashToolParserConfig {
interface BashToolParserConfig {
/** Session ID this parser belongs to */
sessionId: string;
/** Whether the parser is enabled (default: true) */
+3 -3
View File
@@ -48,7 +48,7 @@ const STREAM_INACTIVITY_TIMEOUT_MS = INACTIVITY_TIMEOUT_MS;
/**
* Represents an active file stream.
*/
export interface FileStream {
interface FileStream {
/** Unique stream identifier */
id: string;
/** Session this stream belongs to */
@@ -74,7 +74,7 @@ export interface FileStream {
/**
* Options for creating a file stream.
*/
export interface CreateStreamOptions {
interface CreateStreamOptions {
/** Session ID requesting the stream */
sessionId: string;
/** Path to the file to stream */
@@ -94,7 +94,7 @@ export interface CreateStreamOptions {
/**
* Result of creating a stream.
*/
export interface CreateStreamResult {
interface CreateStreamResult {
success: boolean;
streamId?: string;
error?: string;
+36
View File
@@ -84,6 +84,42 @@ export function generateHooksConfig(): { hooks: Record<string, unknown[]> } {
};
}
/**
* Remove a subset of env keys from .claude/settings.local.json.env if present.
* Used during the disk→tmux-setenv migration: when the caller is actively setting
* a fresh value for a Codeman-managed key, any stale disk entry for THAT KEY is
* superseded and should be removed. Keys NOT in `keysToRemove` are left alone
* (they may be user-managed). No-op if the file/keys don't exist.
*/
export async function stripCaseEnvKeys(casePath: string, keysToRemove: readonly string[]): Promise<void> {
if (keysToRemove.length === 0) return;
const settingsPath = join(casePath, '.claude', 'settings.local.json');
if (!existsSync(settingsPath)) return;
let existing: Record<string, unknown>;
try {
existing = JSON.parse(await readFile(settingsPath, 'utf-8'));
} catch {
return; // Malformed — don't rewrite it
}
const env = existing.env as Record<string, string> | undefined;
if (!env) return;
let changed = false;
for (const key of keysToRemove) {
if (key in env) {
delete env[key];
changed = true;
}
}
if (!changed) return;
existing.env = env;
await writeFile(settingsPath, JSON.stringify(existing, null, 2) + '\n');
}
/**
* Updates env vars in .claude/settings.local.json for the given case path.
* Merges with existing env field; removes vars set to empty string.
-5
View File
@@ -17,11 +17,6 @@ import { KeyedDebouncer } from './utils/index.js';
// ========== Types ==========
export interface ImageWatcherEvents {
'image:detected': (event: ImageDetectedEvent) => void;
'image:error': (error: Error, sessionId?: string) => void;
}
// ========== Constants ==========
/** Supported image file extensions (lowercase) */
+4
View File
@@ -63,6 +63,8 @@ export interface CreateSessionOptions {
openCodeConfig?: OpenCodeConfig;
/** When restoring after reboot, resume a previous Claude conversation by its session ID */
resumeSessionId?: string;
/** Extra env vars exported before launching the CLI (e.g., CLAUDE_CODE_EFFORT_LEVEL). Ephemeral — not written to disk. */
envOverrides?: Record<string, string>;
}
/** Options for respawning a dead pane. */
@@ -77,6 +79,8 @@ export interface RespawnPaneOptions {
openCodeConfig?: OpenCodeConfig;
/** Resume a previous Claude conversation when respawning */
resumeSessionId?: string;
/** Extra env vars exported before launching the CLI (preserved across respawns). */
envOverrides?: Record<string, string>;
}
/**
-15
View File
@@ -60,21 +60,6 @@ const POST_PHASE_DELAY_MS = 1000;
// Events
// ═══════════════════════════════════════════════════════════════
export interface OrchestratorLoopEvents {
stateChanged: (state: OrchestratorState, prevState: OrchestratorState) => void;
planProgress: (phase: string, detail: string) => void;
planReady: (plan: OrchestratorPlan) => void;
phaseStarted: (phase: OrchestratorPhase) => void;
phaseCompleted: (phase: OrchestratorPhase) => void;
phaseFailed: (phase: OrchestratorPhase, reason: string) => void;
taskAssigned: (task: OrchestratorTask, sessionId: string) => void;
taskCompleted: (task: OrchestratorTask) => void;
taskFailed: (task: OrchestratorTask, error: string) => void;
verificationResult: (phase: OrchestratorPhase, result: VerificationResult) => void;
completed: (stats: OrchestratorStats) => void;
error: (error: Error) => void;
}
// ═══════════════════════════════════════════════════════════════
// OrchestratorLoop
// ═══════════════════════════════════════════════════════════════
+2 -2
View File
@@ -68,7 +68,7 @@ export interface DetailedPlanResult {
export type ProgressCallback = (phase: string, detail: string) => void;
export interface PlanSubagentEvent {
interface PlanSubagentEvent {
type: 'started' | 'progress' | 'completed' | 'failed';
agentId: string;
agentType: 'research' | 'planner';
@@ -80,7 +80,7 @@ export interface PlanSubagentEvent {
error?: string;
}
export type SubagentCallback = (event: PlanSubagentEvent) => void;
type SubagentCallback = (event: PlanSubagentEvent) => void;
// ============================================================================
// JSON Repair Helper
+1 -7
View File
@@ -7,10 +7,4 @@
export { RESEARCH_AGENT_PROMPT } from './research-agent.js';
export { PLANNER_PROMPT } from './planner.js';
export {
PHASE_EXECUTION_PROMPT,
TEAM_LEAD_PROMPT,
VERIFICATION_PROMPT,
REPLAN_PROMPT,
SINGLE_TASK_PROMPT,
} from './orchestrator.js';
export { PHASE_EXECUTION_PROMPT, TEAM_LEAD_PROMPT, REPLAN_PROMPT, SINGLE_TASK_PROMPT } from './orchestrator.js';
-16
View File
@@ -57,22 +57,6 @@ Suggested teammate roles:
Each teammate should focus on their assigned task area. Monitor their progress.
When ALL tasks are complete and you've verified the results, output: <promise>{COMPLETION_PHRASE}</promise>`;
/**
* Verification prompt — asks Claude to verify phase completion.
*
* Placeholders:
* - {PHASE_NAME}: Phase name
* - {CRITERIA}: Numbered verification criteria
* - {PASS_PHRASE}: Phrase to output on success
* - {FAIL_PHRASE}: Phrase to output on failure
*/
export const VERIFICATION_PROMPT = `Review the work done in "{PHASE_NAME}". Check these criteria:
{CRITERIA}
If ALL criteria are met, respond with: {PASS_PHRASE}
If ANY criteria fail, respond with: {FAIL_PHRASE} and explain what failed.`;
/**
* Replan prompt — gives failure context and asks for recovery.
*
+1 -1
View File
@@ -24,7 +24,7 @@ const YAML_LINE_PATTERN = /^([a-zA-Z_-]+):\s*"?([^"\n]+)"?\s*$/gm;
/**
* Ralph Loop configuration from .claude/ralph-loop.local.md
*/
export interface RalphLoopConfig {
interface RalphLoopConfig {
enabled: boolean;
iteration: number;
maxIterations: number | null;
+1 -9
View File
@@ -34,19 +34,11 @@ import { RalphLoopStatus, getErrorMessage } from './types.js';
/**
* Events emitted by RalphLoop
*/
export interface RalphLoopEvents {
started: () => void;
stopped: () => void;
taskAssigned: (taskId: string, sessionId: string) => void;
taskCompleted: (taskId: string) => void;
taskFailed: (taskId: string, error: string) => void;
error: (error: Error) => void;
}
/**
* Configuration options for RalphLoop
*/
export interface RalphLoopOptions {
interface RalphLoopOptions {
/** How often to check for new tasks (default from config) */
pollIntervalMs?: number;
/** Minimum time to run before stopping (null = no minimum) */
-30
View File
@@ -19,7 +19,6 @@
* Key exports:
* - `RalphTracker` class — main tracker, extends EventEmitter
* - `RalphTrackerEvents` interface — typed event map
* - Re-exports: `EnhancedPlanTask`, `CheckpointReview` from ralph-plan-tracker
*
* Key methods: `processData(data)` — feed terminal output, `getState()`,
* `getTodos()`, `getCompletionHistory()`, `getPlanTasks()`, `reset()`
@@ -66,9 +65,6 @@ import { RalphStallDetector } from './ralph-stall-detector.js';
import { RalphStatusParser } from './ralph-status-parser.js';
import { STALE_DATA_MAX_AGE_MS, INACTIVITY_TIMEOUT_MS } from './config/server-timing.js';
// Re-export sub-module types for backward compatibility
export type { EnhancedPlanTask, CheckpointReview } from './ralph-plan-tracker.js';
// ========== Configuration Constants ==========
// Note: MAX_TODOS_PER_SESSION and MAX_LINE_BUFFER_SIZE are imported from config modules
@@ -390,32 +386,6 @@ const P2_PRIORITY_PATTERNS = [
* @event circuitBreakerUpdate - Fired when circuit breaker state changes
* @event exitGateMet - Fired when dual-condition exit gate is met
*/
export interface RalphTrackerEvents {
/** Emitted when loop state changes */
loopUpdate: (state: RalphTrackerState) => void;
/** Emitted when todo list is modified */
todoUpdate: (todos: RalphTodoItem[]) => void;
/** Emitted when completion phrase detected (loop finished) */
completionDetected: (phrase: string) => void;
/** Emitted when tracker auto-enables from disabled state */
enabled: () => void;
/** Emitted when a RALPH_STATUS block is parsed */
statusBlockDetected: (block: RalphStatusBlock) => void;
/** Emitted when circuit breaker state changes */
circuitBreakerUpdate: (status: CircuitBreakerStatus) => void;
/** Emitted when dual-condition exit gate is met (completion indicators >= 2 AND EXIT_SIGNAL: true) */
exitGateMet: (data: { completionIndicators: number; exitSignal: boolean }) => void;
/** Emitted when iteration count hasn't changed for an extended period (stall warning) */
iterationStallWarning: (data: { iteration: number; stallDurationMs: number }) => void;
/** Emitted when iteration count hasn't changed for critical period (stall critical) */
iterationStallCritical: (data: { iteration: number; stallDurationMs: number }) => void;
/** Emitted when a common/risky completion phrase is detected (P1-002) */
phraseValidationWarning: (data: {
phrase: string;
reason: 'common' | 'short' | 'numeric';
suggestedPhrase: string;
}) => void;
}
/**
* RalphTracker - Parses terminal output to detect Ralph Wiggum loops and todos
+6 -63
View File
@@ -27,7 +27,7 @@
* - `RespawnController` class — state machine, extends EventEmitter
* - `RespawnConfig` interface — all configuration options
* - `RespawnState` type — union of all state machine states
* - `DetectionStatus`, `ActiveTimerInfo`, `RespawnEvents` — status/event types
* - `DetectionStatus`, `ActiveTimerInfo` — status types
*
* Key methods: `start()`, `stop()`, `getStatus()`, `getConfig()`,
* `getDetectionStatus()`, `getActiveTimers()`, `getAggregateMetrics()`,
@@ -46,8 +46,8 @@
import { EventEmitter } from 'node:events';
import { randomUUID } from 'node:crypto';
import { Session } from './session.js';
import { AiIdleChecker, type AiCheckResult, type AiCheckState } from './ai-idle-checker.js';
import { AiPlanChecker, type AiPlanCheckResult } from './ai-plan-checker.js';
import { AiIdleChecker, type AiCheckState } from './ai-idle-checker.js';
import { AiPlanChecker } from './ai-plan-checker.js';
import type { TeamWatcher } from './team-watcher.js';
import { BufferAccumulator, ANSI_ESCAPE_PATTERN_SIMPLE, assertNever, CleanupManager } from './utils/index.js';
import { MAX_RESPAWN_BUFFER_SIZE, TRIM_RESPAWN_BUFFER_TO as RESPAWN_BUFFER_TRIM_SIZE } from './config/buffer-limits.js';
@@ -99,13 +99,13 @@ const PLAN_MODE_SELECTOR_PATTERN = /[❯>]\s*\d+\./;
* Each layer provides a confidence signal that Claude has finished working.
*/
/** Active timer info for UI display */
export interface ActiveTimerInfo {
interface ActiveTimerInfo {
name: string;
remainingMs: number;
totalMs: number;
}
export interface DetectionStatus {
interface DetectionStatus {
/** Layer 0: Stop hook received (highest priority - definitive signal) */
stopHookReceived: boolean;
/** Timestamp when Stop hook was received */
@@ -488,70 +488,13 @@ export interface RespawnConfig {
* @event error - Fired on errors
* @event log - Fired for debug logging
*/
/** Timer info for countdown display */
export interface TimerInfo {
name: string;
durationMs: number;
endsAt: number;
reason?: string;
}
/** Action log entry for detailed UI feedback */
export interface ActionLogEntry {
interface ActionLogEntry {
type: string;
detail: string;
timestamp: number;
}
export interface RespawnEvents {
/** State machine transition */
stateChanged: (state: RespawnState, prevState: RespawnState) => void;
/** New respawn cycle started */
respawnCycleStarted: (cycleNumber: number) => void;
/** Respawn cycle finished */
respawnCycleCompleted: (cycleNumber: number) => void;
/** Command sent to session */
stepSent: (step: string, input: string) => void;
/** Step completed (ready indicator detected) */
stepCompleted: (step: string) => void;
/** Detection status update for UI display */
detectionUpdate: (status: DetectionStatus) => void;
/** Auto-accept sent for plan mode approval */
autoAcceptSent: () => void;
/** AI idle check started */
aiCheckStarted: () => void;
/** AI idle check completed with verdict */
aiCheckCompleted: (result: AiCheckResult) => void;
/** AI idle check failed */
aiCheckFailed: (error: string) => void;
/** AI idle check cooldown state changed */
aiCheckCooldown: (active: boolean, endsAt: number | null) => void;
/** AI plan check started */
planCheckStarted: () => void;
/** AI plan check completed with verdict */
planCheckCompleted: (result: AiPlanCheckResult) => void;
/** AI plan check failed */
planCheckFailed: (error: string) => void;
/** Timer started for countdown display */
timerStarted: (timer: TimerInfo) => void;
/** Timer cancelled */
timerCancelled: (timerName: string, reason?: string) => void;
/** Timer completed */
timerCompleted: (timerName: string) => void;
/** Verbose action log for detailed UI feedback */
actionLog: (action: ActionLogEntry) => void;
/** Error occurred */
error: (error: Error) => void;
/** Debug log message */
log: (message: string) => void;
/** Stuck state warning emitted */
stuckStateWarning: (state: RespawnState, durationMs: number) => void;
/** Stuck state recovery triggered */
stuckStateRecovery: (state: RespawnState, durationMs: number, attempt: number) => void;
/** Respawn blocked by external signal */
respawnBlocked: (data: { reason: string; details: string }) => void;
}
/**
* Convert milliseconds to a non-negative whole number of seconds for countdown display.
* Rounds up so that e.g. 1200 ms shows as 2 s (never under-reports remaining time).
+1 -7
View File
@@ -93,7 +93,7 @@ const DEFAULT_AUTO_COMPACT_THRESHOLD = 110_000;
/**
* Callbacks required by SessionAutoOps to interact with the parent Session.
*/
export interface AutoOpsCallbacks {
interface AutoOpsCallbacks {
/** Send a command via the terminal multiplexer */
writeCommand: (command: string) => Promise<boolean>;
/** Check if Claude is currently working */
@@ -109,12 +109,6 @@ export interface AutoOpsCallbacks {
/**
* Events emitted by SessionAutoOps.
*/
export interface SessionAutoOpsEvents {
/** Auto-compact was triggered and the /compact command was sent */
autoCompact: (data: { tokens: number; threshold: number; prompt?: string }) => void;
/** Auto-clear was triggered and the /clear command was sent */
autoClear: (data: { tokens: number; threshold: number }) => void;
}
/**
* Manages auto-compact and auto-clear automation for a Session.
+1 -1
View File
@@ -15,7 +15,7 @@ import { getAugmentedPath } from './utils/index.js';
* Build Claude CLI permission flags based on the configured mode.
* Returns an array of args to pass to the CLI.
*/
export function buildPermissionArgs(claudeMode: ClaudeMode, allowedTools?: string): string[] {
function buildPermissionArgs(claudeMode: ClaudeMode, allowedTools?: string): string[] {
switch (claudeMode) {
case 'dangerously-skip-permissions':
return ['--dangerously-skip-permissions'];
+10 -14
View File
@@ -30,18 +30,6 @@ import { SessionState } from './types.js';
/**
* Events emitted by SessionManager
*/
export interface SessionManagerEvents {
/** Fired when a new session starts successfully */
sessionStarted: (session: Session) => void;
/** Fired when a session stops (graceful or forced) */
sessionStopped: (sessionId: string) => void;
/** Fired when a session encounters an error */
sessionError: (sessionId: string, error: string) => void;
/** Fired when a session produces terminal output */
sessionOutput: (sessionId: string, output: string) => void;
/** Fired when a completion phrase is detected */
sessionCompletion: (sessionId: string, phrase: string) => void;
}
/**
* Manages multiple Claude sessions with lifecycle coordination.
@@ -164,7 +152,7 @@ export class SessionManager extends EventEmitter {
await session.start();
this.sessions.set(session.id, session);
this.store.setSession(session.id, session.toState());
this.updateSessionState(session);
this.emit('sessionStarted', session);
return session;
@@ -259,7 +247,15 @@ export class SessionManager extends EventEmitter {
}
private updateSessionState(session: Session): void {
this.store.setSession(session.id, session.toState());
// envOverrides is intentionally NOT on SessionState (API safety). For disk
// persistence we augment the stored object with __envOverrides so reboot
// recovery can restore them without leaking through any API serializer.
// The key uses the reserved `__` prefix so it is visibly "internal" to any
// future reader of state.json.
const state = session.toState();
const envOverrides = session.getEnvOverridesForPersist();
const toStore = envOverrides ? { ...state, __envOverrides: envOverrides } : state;
this.store.setSession(session.id, toStore as SessionState);
}
/** Gets all sessions from persistent storage (including stopped). */
+54 -62
View File
@@ -154,63 +154,6 @@ export interface ClaudeMessage {
* Event signatures emitted by the Session class.
* Subscribe using `session.on('eventName', handler)`.
*/
export interface SessionEvents {
/** Processed text output (ANSI stripped) */
output: (data: string) => void;
/** Parsed JSON message from Claude CLI */
message: (msg: ClaudeMessage) => void;
/** Error output from the session */
error: (data: string) => void;
/** Session process exited */
exit: (code: number | null) => void;
/** One-shot prompt completed with result and cost */
completion: (result: string, cost: number) => void;
/** Raw terminal data (includes ANSI codes) */
terminal: (data: string) => void;
/** Signal to clear terminal display (after mux attach) */
clearTerminal: () => void;
/** New background task started */
taskCreated: (task: BackgroundTask) => void;
/** Background task status changed */
taskUpdated: (task: BackgroundTask) => void;
/** Background task finished successfully */
taskCompleted: (task: BackgroundTask) => void;
/** Background task failed with error */
taskFailed: (task: BackgroundTask, error: string) => void;
/** Auto-clear triggered due to token threshold */
autoClear: (data: { tokens: number; threshold: number }) => void;
/** Auto-compact triggered due to token threshold */
autoCompact: (data: { tokens: number; threshold: number; prompt?: string }) => void;
/** Ralph loop state changed */
ralphLoopUpdate: (state: RalphTrackerState) => void;
/** Ralph todo list updated */
ralphTodoUpdate: (todos: RalphTodoItem[]) => void;
/** Ralph completion phrase detected */
ralphCompletionDetected: (phrase: string) => void;
/** RALPH_STATUS block detected */
ralphStatusBlockDetected: (block: import('./types.js').RalphStatusBlock) => void;
/** Circuit breaker state changed */
ralphCircuitBreakerUpdate: (status: import('./types.js').CircuitBreakerStatus) => void;
/** Dual-condition exit gate met */
ralphExitGateMet: (data: { completionIndicators: number; exitSignal: boolean }) => void;
/** Bash tool with file paths started */
bashToolStart: (tool: ActiveBashTool) => void;
/** Bash tool completed */
bashToolEnd: (tool: ActiveBashTool) => void;
/** Active Bash tools list updated */
bashToolsUpdate: (tools: ActiveBashTool[]) => void;
/** CLI info (version, model, account) updated */
cliInfoUpdated: (info: {
version: string | null;
model: string | null;
accountType: string | null;
latestVersion: string | null;
}) => void;
}
// SessionMode is imported from types.ts (single source of truth)
// Re-export for backwards compatibility with any external consumers
export type { SessionMode } from './types.js';
/**
* Core session class that wraps a PTY process running Claude CLI or a shell.
@@ -330,6 +273,10 @@ export class Session extends EventEmitter {
private _openCodeConfig: OpenCodeConfig | undefined;
private _resumeSessionId: string | undefined;
// Ephemeral env overrides (e.g., CLAUDE_CODE_EFFORT_LEVEL). Exported by tmux at spawn,
// preserved across respawns via persisted state. Not written to .claude/settings.local.json.
private _envOverrides: Record<string, string> | undefined;
// Session color for visual differentiation
private _color: import('./types.js').SessionColor = 'default';
@@ -389,6 +336,8 @@ export class Session extends EventEmitter {
openCodeConfig?: OpenCodeConfig;
/** Resume a previous Claude conversation (used after server reboot) */
resumeSessionId?: string;
/** Extra env vars exported to the CLI at spawn time (no disk persistence) */
envOverrides?: Record<string, string>;
}
) {
super();
@@ -436,6 +385,11 @@ export class Session extends EventEmitter {
this._openCodeConfig = config.openCodeConfig;
}
// Apply env overrides (exported at spawn, not persisted to disk)
if (config.envOverrides && Object.keys(config.envOverrides).length > 0) {
this._envOverrides = { ...config.envOverrides };
}
// Initialize task tracker and forward events (store handlers for cleanup)
this._taskTracker = new TaskTracker();
this._taskTrackerHandlers = {
@@ -530,6 +484,15 @@ export class Session extends EventEmitter {
return this._claudeSessionId;
}
// Adopt a Claude conversation ID observed from an external source (e.g. hook
// payload). In interactive PTY mode Claude CLI emits no JSON to stdout, so
// `_handleJsonMessage` never sees `session_id`; hooks are the only signal
// that conveys a post-/clear conversation switch.
adoptClaudeSessionId(newId: string): void {
if (!newId || newId === this._claudeSessionId) return;
this._claudeSessionId = newId;
}
/** The tmux session name, if the session is running inside a mux */
get muxName(): string | null {
return this._muxSession?.muxName ?? null;
@@ -846,9 +809,29 @@ export class Session extends EventEmitter {
cliLatestVersion: this._cliLatestVersion || undefined,
openCodeConfig: this._openCodeConfig,
resumeSessionId: this._resumeSessionId,
// envOverrides intentionally NOT on the public SessionState type — they must not
// leak into SSE / GET /api/sessions broadcasts (schema allows OPENCODE_*, which
// can carry secrets). For disk persistence, session-manager calls
// getEnvOverridesForPersist() and writes alongside state.
};
}
/**
* Returns a subset of env overrides safe for disk persistence (state.json).
* Only non-sensitive `CLAUDE_CODE_*` keys are included. `OPENCODE_*` keys are
* filtered out because the schema permits them and they can carry secrets
* (e.g., OPENCODE_API_KEY); secrets must not land in `~/.codeman/state.json`.
* Must NOT be included in any API-bound serializer — see toState() comment.
*/
getEnvOverridesForPersist(): Record<string, string> | undefined {
if (!this._envOverrides) return undefined;
const safe: Record<string, string> = {};
for (const [key, value] of Object.entries(this._envOverrides)) {
if (key.startsWith('CLAUDE_CODE_')) safe[key] = value;
}
return Object.keys(safe).length > 0 ? safe : undefined;
}
toDetailedState() {
return {
...this.toLightDetailedState(),
@@ -1014,6 +997,7 @@ export class Session extends EventEmitter {
allowedTools: this._allowedTools,
openCodeConfig: this._openCodeConfig,
resumeSessionId: this._resumeSessionId,
envOverrides: this._envOverrides,
},
createSessionOptions: {
sessionId: this.id,
@@ -1026,6 +1010,7 @@ export class Session extends EventEmitter {
allowedTools: this._allowedTools,
openCodeConfig: this._openCodeConfig,
resumeSessionId: this._resumeSessionId,
envOverrides: this._envOverrides,
},
spawnErrLabel: 'mux attachment',
});
@@ -1101,7 +1086,8 @@ export class Session extends EventEmitter {
cols: 120,
rows: 40,
cwd: this.workingDir,
env: buildClaudeEnv(this.id),
// Merge envOverrides after buildClaudeEnv so user settings shadow defaults.
env: { ...buildClaudeEnv(this.id), ...(this._envOverrides ?? {}) },
});
} catch (spawnErr) {
console.error('[Session] Failed to spawn Claude PTY:', spawnErr);
@@ -1346,6 +1332,7 @@ export class Session extends EventEmitter {
workingDir: this.workingDir,
mode: 'shell',
niceConfig: this._niceConfig,
envOverrides: this._envOverrides,
},
createSessionOptions: {
sessionId: this.id,
@@ -1353,6 +1340,7 @@ export class Session extends EventEmitter {
mode: 'shell',
name: this._name,
niceConfig: this._niceConfig,
envOverrides: this._envOverrides,
},
spawnErrLabel: 'shell mux attachment',
});
@@ -1488,7 +1476,8 @@ export class Session extends EventEmitter {
cols: 120,
rows: 40,
cwd: this.workingDir,
env: buildClaudeEnv(this.id),
// Merge envOverrides after buildClaudeEnv so user settings shadow defaults.
env: { ...buildClaudeEnv(this.id), ...(this._envOverrides ?? {}) },
});
} catch (spawnErr) {
console.error('[Session] Failed to spawn Claude PTY for runPrompt:', spawnErr);
@@ -1634,11 +1623,14 @@ export class Session extends EventEmitter {
this._messages = this._messages.slice(-Math.floor(MAX_MESSAGES * 0.8));
}
// Extract Claude session ID from messages (can be in any message type)
// Support both sessionId (camelCase) and session_id (snake_case)
// Extract Claude session ID from messages (can be in any message type).
// Support both sessionId (camelCase) and session_id (snake_case).
// The constructor seeds _claudeSessionId with this.id as a placeholder;
// once Claude CLI emits its real session ID, adopt it so JSONL lookups
// (e.g. /api/sessions/:id/last-response) can find the transcript file.
const msgSessionId =
((msg as unknown as Record<string, unknown>).sessionId as string | undefined) ?? msg.session_id;
if (msgSessionId && !this._claudeSessionId) {
if (msgSessionId && msgSessionId !== this._claudeSessionId) {
this._claudeSessionId = msgSessionId;
}
-11
View File
@@ -134,17 +134,6 @@ export interface SubagentToolResult {
isError: boolean; // Whether result is an error
}
export interface SubagentEvents {
'subagent:discovered': (info: SubagentInfo) => void;
'subagent:updated': (info: SubagentInfo) => void;
'subagent:tool_call': (data: SubagentToolCall) => void;
'subagent:tool_result': (data: SubagentToolResult) => void;
'subagent:progress': (data: SubagentProgress) => void;
'subagent:message': (data: SubagentMessage) => void;
'subagent:completed': (info: SubagentInfo) => void;
'subagent:error': (error: Error, agentId?: string) => void;
}
// ========== Constants ==========
const CLAUDE_PROJECTS_DIR = join(homedir(), '.claude/projects');
-8
View File
@@ -17,14 +17,6 @@ import { getStore } from './state-store.js';
/**
* Events emitted by TaskQueue
*/
export interface TaskQueueEvents {
/** Fired when a task is added to the queue */
taskAdded: (task: Task) => void;
/** Fired when a task is removed from the queue */
taskRemoved: (taskId: string) => void;
/** Fired when a task's state changes */
taskUpdated: (task: Task) => void;
}
/**
* Priority queue for managing tasks with dependency support.
-10
View File
@@ -153,16 +153,6 @@ export interface BackgroundTask {
* @event taskCompleted - Task finished successfully
* @event taskFailed - Task finished with error
*/
export interface TaskTrackerEvents {
/** New task created */
taskCreated: (task: BackgroundTask) => void;
/** Task state updated */
taskUpdated: (task: BackgroundTask) => void;
/** Task completed successfully */
taskCompleted: (task: BackgroundTask) => void;
/** Task failed with error */
taskFailed: (task: BackgroundTask, error: string) => void;
}
/**
* TaskTracker - Detects and tracks background tasks in Claude Code sessions.
+83 -13
View File
@@ -98,6 +98,44 @@ const LEGACY_MUX_NAME_PATTERN = /^claudeman-[a-f0-9-]+$/;
/** Regex to validate tmux pane targets (e.g., "%0", "%1", "0", "1") */
const SAFE_PANE_TARGET_PATTERN = /^(%\d+|\d+)$/;
/**
* Separator used in `tmux list-panes -F` output between session name and pid.
*
* Must NOT be a backslash-escape (e.g. `\t`, `\n`): under non-tty execution
* contexts (launchd on macOS, systemd without TTYPath) tmux can emit such
* escapes as the literal two characters `\` + letter rather than the control
* byte, breaking the parser and causing every tracked session to be classified
* as dead — which wipes state.json on restart. '|' is passed through verbatim
* in every environment and is rejected by tmux's own session-name validation,
* so it cannot appear inside `#{session_name}` and cause a false split.
*/
const PANE_LIST_SEP = '|';
/** Format string for `tmux list-panes -F`. Keep in sync with {@link parsePaneList}. */
const PANE_LIST_FORMAT = `#{session_name}${PANE_LIST_SEP}#{pane_pid}`;
/**
* Parse the output of `tmux list-panes -a -F '#{session_name}|#{pane_pid}'`
* into a Map of session-name → pane pid. Exported for unit testing.
*
* - Skips empty lines and lines without the separator.
* - Skips entries with a non-numeric pid or empty name.
*/
export function parsePaneList(output: string): Map<string, number> {
const result = new Map<string, number>();
for (const line of output.split('\n')) {
if (!line) continue;
const sep = line.indexOf(PANE_LIST_SEP);
if (sep === -1) continue;
const name = line.slice(0, sep);
const pid = parseInt(line.slice(sep + 1), 10);
if (name && !Number.isNaN(pid)) {
result.set(name, pid);
}
}
return result;
}
/** Characters unsafe in paths — shell metacharacters, quotes, and control chars */
const UNSAFE_PATH_CHARS = /[;&|$`(){}<>'"\n\r]/;
@@ -361,6 +399,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
/**
* Build the array of environment export commands shared by createSession() and respawnPane().
* Includes locale, mux markers, session identity, and API URL.
*
* User-supplied envOverrides are NOT inlined here — they go through applyEnvOverrides()
* via `tmux setenv` so secret values (e.g., OPENCODE_API_KEY) never appear in the bash
* command line (visible in `ps`). This also sidesteps shell-metachar injection via keys.
*/
private buildEnvExports(sessionId: string, muxName: string, mode: SessionMode): string[] {
const exports = [
@@ -377,6 +419,35 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
return exports;
}
/**
* Apply user-supplied env overrides to a tmux session via `tmux setenv`.
* Values stay off the bash command line (not visible in `ps`), and are inherited
* by new panes — including `respawn-pane`. Persists at tmux-session level, so
* Codeman server restarts don't lose the setting as long as the tmux session lives.
*
* Key validation is strict (`/^[A-Z_][A-Z0-9_]*$/`) as defense-in-depth against
* shell-metachar injection even if upstream schema check is bypassed.
*/
private applyEnvOverrides(muxName: string, envOverrides?: Record<string, string>): void {
if (!envOverrides) return;
const VALID_KEY = /^[A-Z_][A-Z0-9_]*$/;
for (const [key, value] of Object.entries(envOverrides)) {
if (!value) continue; // Skip empty — nothing to set
if (!VALID_KEY.test(key)) {
console.warn(`[TmuxManager] Skipping invalid env override key: ${JSON.stringify(key)}`);
continue;
}
try {
execSync(`tmux setenv -t ${shellescape(muxName)} ${key} ${shellescape(value)}`, {
timeout: EXEC_TIMEOUT_MS,
stdio: ['pipe', 'pipe', 'pipe'],
});
} catch (err) {
console.warn(`[TmuxManager] Failed to set env override ${key}:`, err);
}
}
}
/**
* Resolve the CLI binary directory and return the PATH export prefix string.
* Returns '' if no override is needed (shell mode) or the binary dir is not found.
@@ -420,6 +491,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
allowedTools,
openCodeConfig,
resumeSessionId,
envOverrides,
} = options;
const muxName = `codeman-${sessionId.slice(0, 8)}`;
@@ -507,6 +579,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
this._configureOpenCode(muxName, openCodeConfig);
}
// Apply user-supplied env overrides (e.g., CLAUDE_CODE_EFFORT_LEVEL) via tmux setenv
// so secret values stay off the bash command line. Must run before respawn-pane.
this.applyEnvOverrides(muxName, envOverrides);
// Replace the shell with the actual command (no echo in terminal)
execSync(`tmux respawn-pane -k -t "${muxName}" bash -c ${JSON.stringify(fullCmd)}`, {
timeout: EXEC_TIMEOUT_MS,
@@ -647,6 +723,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
allowedTools,
openCodeConfig,
resumeSessionId,
envOverrides,
} = options;
const session = this.sessions.get(sessionId);
if (!session) return null;
@@ -678,6 +755,9 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
this._configureOpenCode(muxName, openCodeConfig);
}
// Re-apply user env overrides before respawn so the new shell inherits them.
this.applyEnvOverrides(muxName, envOverrides);
await execAsync(`tmux respawn-pane -k -t "${muxName}" bash -c ${JSON.stringify(fullCmd)}`, {
timeout: EXEC_TIMEOUT_MS,
});
@@ -902,23 +982,13 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
const discovered: string[] = [];
// Batch: single tmux call to get all session names + pane PIDs (replaces N per-session subprocess calls)
const activeSessions = new Map<string, number>();
let activeSessions = new Map<string, number>();
try {
const output = execSync("tmux list-panes -a -F '#{session_name}\t#{pane_pid}' 2>/dev/null || true", {
const output = execSync(`tmux list-panes -a -F '${PANE_LIST_FORMAT}' 2>/dev/null || true`, {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
}).trim();
for (const line of output.split('\n')) {
if (!line) continue;
const sep = line.indexOf('\t');
if (sep === -1) continue;
const name = line.slice(0, sep);
const pid = parseInt(line.slice(sep + 1), 10);
if (name && !Number.isNaN(pid)) {
activeSessions.set(name, pid);
}
}
activeSessions = parsePaneList(output);
} catch (err) {
console.error('[TmuxManager] Failed to list tmux panes:', err);
}
+2 -15
View File
@@ -20,7 +20,7 @@ import { createInterface } from 'node:readline';
/**
* Parsed transcript entry from the JSONL file
*/
export interface TranscriptEntry {
interface TranscriptEntry {
type: 'user' | 'assistant' | 'system' | 'result';
timestamp: string;
message?: {
@@ -35,7 +35,7 @@ export interface TranscriptEntry {
};
}
export interface TranscriptContentBlock {
interface TranscriptContentBlock {
type: 'text' | 'tool_use' | 'tool_result';
text?: string;
name?: string;
@@ -68,15 +68,6 @@ export interface TranscriptState {
lastUpdateAt: string | null;
}
export interface TranscriptWatcherEvents {
'transcript:update': (state: TranscriptState) => void;
'transcript:complete': (state: TranscriptState) => void;
'transcript:tool_start': (toolName: string) => void;
'transcript:tool_end': (toolName: string, isError: boolean) => void;
'transcript:error': (error: Error) => void;
'transcript:plan_mode': () => void;
}
// ========== Constants ==========
/** How often to check for new content when file watching fails */
@@ -435,7 +426,3 @@ export class TranscriptWatcher extends EventEmitter {
}
}
}
// ========== Singleton Export ==========
export const transcriptWatcher = new TranscriptWatcher();
+1 -1
View File
@@ -33,7 +33,7 @@ import { getErrorMessage } from './types.js';
// ========== Types ==========
export interface TunnelStatus {
interface TunnelStatus {
running: boolean;
url: string | null;
}
-15
View File
@@ -268,18 +268,3 @@ export function createInitialOrchestratorStats(): OrchestratorStats {
replanCount: 0,
};
}
/** Factory function for initial orchestrator persist state */
export function createInitialOrchestratorPersistState(
config: OrchestratorConfig = DEFAULT_ORCHESTRATOR_CONFIG
): OrchestratorPersistState {
return {
state: 'idle',
plan: null,
currentPhaseIndex: 0,
startedAt: null,
completedAt: null,
config,
stats: createInitialOrchestratorStats(),
};
}
-16
View File
@@ -230,22 +230,6 @@ export interface TimingHistory {
/**
* Named respawn configuration preset for quick setup
*/
export interface RespawnPreset {
/** Unique preset identifier */
id: string;
/** User-friendly preset name */
name: string;
/** Description of when to use this preset */
description?: string;
/** The respawn configuration (without enabled flag) */
config: Omit<RespawnConfig, 'enabled'>;
/** Duration in minutes (optional default) */
durationMinutes?: number;
/** Whether this is a built-in preset */
builtIn?: boolean;
/** Timestamp when created */
createdAt: number;
}
/**
* Persisted respawn configuration for mux sessions.
-8
View File
@@ -150,11 +150,3 @@ export interface SessionState {
/**
* Output captured from a session
*/
export interface SessionOutput {
/** Standard output content */
stdout: string;
/** Standard error content */
stderr: string;
/** Exit code of the process, null if still running */
exitCode: number | null;
}
+1 -1
View File
@@ -14,7 +14,7 @@ import type { Disposable, CleanupRegistration, CleanupResourceType } from '../ty
/**
* Options for setTimeout/setInterval with automatic cleanup.
*/
export interface TimerOptions {
interface TimerOptions {
/** Human-readable description for debugging */
description?: string;
}
+3 -6
View File
@@ -7,17 +7,14 @@
*/
export { BufferAccumulator } from './buffer-accumulator.js';
export { LRUMap, type LRUMapOptions } from './lru-map.js';
export { CleanupManager, type TimerOptions } from './cleanup-manager.js';
export { CleanupManager } from './cleanup-manager.js';
export { Debouncer, KeyedDebouncer } from './debouncer.js';
export { StaleExpirationMap, type StaleExpirationMapOptions } from './stale-expiration-map.js';
export { StaleExpirationMap } from './stale-expiration-map.js';
export {
ANSI_ESCAPE_PATTERN_FULL,
ANSI_ESCAPE_PATTERN_SIMPLE,
TOKEN_PATTERN,
SPINNER_PATTERN,
createAnsiPatternFull,
createAnsiPatternSimple,
stripAnsi,
SAFE_PATH_PATTERN,
execPattern,
@@ -27,4 +24,4 @@ export { stringSimilarity, fuzzyPhraseMatch, todoContentHash } from './string-si
export { assertNever } from './type-safety.js';
export { wrapWithNice } from './nice-wrapper.js';
export { findClaudeDir, getAugmentedPath } from './claude-cli-resolver.js';
export { resolveOpenCodeDir, isOpenCodeAvailable } from './opencode-cli-resolver.js';
export { resolveOpenCodeDir } from './opencode-cli-resolver.js';
+1 -1
View File
@@ -10,7 +10,7 @@
/**
* Configuration options for LRUMap.
*/
export interface LRUMapOptions<K, V> {
interface LRUMapOptions<K, V> {
/** Maximum number of entries before eviction */
maxSize: number;
/** Optional callback when an entry is evicted */
-18
View File
@@ -40,24 +40,6 @@ export const ANSI_ESCAPE_PATTERN_SIMPLE = /\x1b\[[0-9;]*[A-Za-z]/g;
*/
export const TOKEN_PATTERN = /(\d+(?:\.\d+)?)\s*([kKmM])?\s*tokens/;
/**
* Creates a fresh copy of ANSI_ESCAPE_PATTERN_FULL.
* Use when you need a pattern without shared lastIndex state.
*/
export function createAnsiPatternFull(): RegExp {
// eslint-disable-next-line no-control-regex
return /\x1b(?:\[[0-9;?]*[A-Za-z]|\][^\x07\x1b]*(?:\x07|\x1b\\)|[=>])/g;
}
/**
* Creates a fresh copy of ANSI_ESCAPE_PATTERN_SIMPLE.
* Use when you need a pattern without shared lastIndex state.
*/
export function createAnsiPatternSimple(): RegExp {
// eslint-disable-next-line no-control-regex
return /\x1b\[[0-9;]*[A-Za-z]/g;
}
/**
* Strips ANSI escape codes from text using the comprehensive pattern.
* @param text - Text containing ANSI escape codes
+1 -1
View File
@@ -22,7 +22,7 @@ interface TimedEntry<V> {
/**
* Configuration options for StaleExpirationMap.
*/
export interface StaleExpirationMapOptions<K, V> {
interface StaleExpirationMapOptions<K, V> {
/** Time-to-live in milliseconds before entries expire */
ttlMs: number;
/** How often to run cleanup (default: ttlMs / 2) */
+1 -1
View File
@@ -23,7 +23,7 @@ import {
export const AUTH_COOKIE_NAME = 'codeman_session';
/** State returned from registerAuthMiddleware for cleanup in server stop() */
export interface AuthState {
interface AuthState {
authSessions: StaleExpirationMap<string, AuthSessionRecord> | null;
authFailures: StaleExpirationMap<string, number> | null;
qrAuthFailures: StaleExpirationMap<string, number> | null;
+1 -1
View File
@@ -11,5 +11,5 @@ export type { EventPort } from './event-port.js';
export type { RespawnPort } from './respawn-port.js';
export type { ConfigPort } from './config-port.js';
export type { InfraPort, ScheduledRun } from './infra-port.js';
export type { AuthPort, AuthSessionRecord } from './auth-port.js';
export type { AuthPort } from './auth-port.js';
export type { OrchestratorPort } from './orchestrator-port.js';
+157 -19
View File
@@ -908,11 +908,9 @@ class CodemanApp {
const tpl = document.createElement('template');
tpl.innerHTML = html;
const frag = tpl.content;
// Remove dangerous elements
for (const el of frag.querySelectorAll('script, iframe, object, embed, form, base, meta, link, style')) {
el.remove();
}
// Strip dangerous attributes from all elements
for (const el of frag.querySelectorAll('*')) {
for (const attr of [...el.attributes]) {
const name = attr.name.toLowerCase();
@@ -926,17 +924,143 @@ class CodemanApp {
}
}
}
// Serialize back via a container
const div = document.createElement('div');
div.appendChild(frag);
return div.innerHTML;
}
/**
* Strip ANSI escape sequences and Claude CLI chrome (status bar, hints,
* spinner, progress bar) from a terminal buffer so the response viewer can
* show just the conversational text when the JSONL transcript is missing.
*/
_cleanTerminalBuffer(buf) {
const stripped = buf
// CSI sequences — params (0x30-0x3F includes digits, ?, ;, <, =, >),
// intermediates (0x20-0x2F), final byte (0x40-0x7E). Catches \x1b[>c,
// \x1b[>q, \x1b[?25l etc. that the previous regex missed.
.replace(/\x1b\[[\x30-\x3F]*[\x20-\x2F]*[\x40-\x7E]/g, '')
// OSC sequences (window titles etc.) terminated by BEL or ST
.replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '')
// DCS / APC / PM / SOS sequences
.replace(/\x1b[PX^_][^\x1b]*\x1b\\/g, '')
// SS2/SS3 + charset selects + single-char escapes
.replace(/\x1b[NO()][A-Z0-9]?/g, '')
.replace(/\x1b[>=<78cDEHM]/g, '')
// Stray control chars (except \t \n)
.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, '')
.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
// Drop Claude CLI chrome lines that aren't part of the response.
const CHROME_PATTERNS = [
/^\s*❯\s*/, // shell prompt
/^\s*[⏵⏺⏸⏹]+\s*/, // status glyphs
/^\s*✻\s*(Crunching|Crunched|Thinking)/i, // spinner lines
/bypass permissions/i,
/\bshift\+tab to cycle\b/i,
/^\s*focus\s*$/,
/^\s*new task\?/i,
/\/clear to save/i,
/^\s*─{5,}\s*$/, // horizontal dividers
/\[(Opus|Sonnet|Haiku|GPT|Claude)[\s\S]*(tokens?|\$|¥|%|↑|↓)/i, // status bar
/^\s*\[\d+[km]?\/\d+[km]?\]/i, // token counter
/[█░▓▒]{3,}/, // progress bar
/^\s*\(.*\s*(tokens?|context).*\)\s*$/i,
];
const lines = stripped.split('\n');
const kept = lines.filter((line) => {
const trimmed = line.trim();
if (!trimmed) return true; // keep blanks so paragraphs survive
return !CHROME_PATTERNS.some((re) => re.test(line));
});
return kept
.join('\n')
.replace(/[ \t]+$/gm, '')
.replace(/\n{4,}/g, '\n\n\n')
.trim();
}
/**
* Wrap ASCII/box diagrams in fenced code blocks so marked.js preserves whitespace.
* Claude often emits box-drawing diagrams without triple-backticks; without this
* step, HTML collapses the whitespace and the diagram becomes unreadable prose.
*/
_preprocessAsciiArt(text) {
// Only trigger on characters that rarely appear in prose:
// U+2500-U+257F Box Drawing (─│┌┐└┘├┤┬┴┼╔╗╚╝═║)
// U+2580-U+259F Block Elements (▀▄█▌▐░▒▓, progress bars)
// Deliberately excluded:
// U+2190-U+21FF Arrows (→←↑↓⇒ — common rhetorical prose)
// U+25A0-U+25FF Geometric Shapes (●○■□◆◇ — common bullets)
// Triggering on those would wrap numbered lists / prose that merely uses
// arrows in code blocks and break their markdown rendering.
const BOX_PATTERN = /[─-╿▀-▟]/;
// Preserve existing fenced code blocks as-is (hide them behind placeholders)
const fenceRe = /```[\s\S]*?```/g;
const placeholders = [];
const masked = text.replace(fenceRe, (m) => {
placeholders.push(m);
return `FENCE${placeholders.length - 1}`;
});
// Split on blank-line paragraph boundaries; wrap any paragraph containing
// box-drawing/arrow chars in its own fenced block.
const processed = masked
.split(/(\n{2,})/)
.map((chunk) => {
if (/^\n{2,}$/.test(chunk)) return chunk; // keep separators
if (!chunk.trim()) return chunk;
if (chunk.includes('FENCE')) return chunk;
if (BOX_PATTERN.test(chunk)) return '\n```\n' + chunk + '\n```\n';
return chunk;
})
.join('');
return processed.replace(/FENCE(\d+)/g, (_m, i) => placeholders[Number(i)]);
}
/** Render markdown to sanitized HTML, falling back to plain text if marked.js unavailable */
_renderMarkdown(text) {
if (typeof marked !== 'undefined' && marked.parse) {
try {
return this._sanitizeHtml(marked.parse(text, { breaks: true, gfm: true }));
const prepared = this._preprocessAsciiArt(text);
let html = this._sanitizeHtml(marked.parse(prepared, { breaks: true, gfm: true }));
// Wrap tables in a horizontal-scroll container so they overflow gracefully
// on mobile without collapsing into block-level cells.
html = html.replace(/<table>/g, '<div class="rv-table-wrap"><table>')
.replace(/<\/table>/g, '</table></div>');
// Tag code blocks containing box-drawing glyphs as diagrams (same
// narrow trigger as _preprocessAsciiArt — arrows/geometric shapes
// don't count because they appear frequently in prose).
// Default is wrap (readable on mobile); a toggle button lets the user
// switch to horizontal-scroll mode when the original structure matters.
// The button must live OUTSIDE the <pre> scroll container so it stays
// pinned to the visual right edge when the user scrolls horizontally.
const DIAGRAM_CHAR = /[─-╿▀-▟]/;
const tmpl = document.createElement('template');
tmpl.innerHTML = html;
tmpl.content.querySelectorAll('pre > code').forEach((code) => {
if (!DIAGRAM_CHAR.test(code.textContent || '')) return;
const pre = code.parentElement;
pre.classList.add('rv-diagram');
const wrap = document.createElement('div');
wrap.className = 'rv-diagram-wrap';
const btn = document.createElement('button');
btn.className = 'rv-wrap-toggle';
btn.type = 'button';
btn.setAttribute('aria-label', 'Toggle line wrapping');
btn.setAttribute('title', 'Toggle line wrapping');
pre.parentNode.insertBefore(wrap, pre);
wrap.appendChild(btn);
wrap.appendChild(pre);
});
return tmpl.innerHTML;
} catch { /* fall through */ }
}
// Fallback: escape HTML and preserve whitespace
@@ -944,6 +1068,27 @@ class CodemanApp {
return `<pre style="white-space:pre-wrap;word-break:break-word">${escaped}</pre>`;
}
/**
* Bind click handlers inside the response viewer body. Uses event delegation
* so a single listener serves every diagram-toggle button, including those
* added when the conversation is reloaded. Idempotent via a dataset flag.
*/
_bindResponseViewerInteractions(body) {
if (!body || body.dataset.rvBound === '1') return;
body.dataset.rvBound = '1';
body.addEventListener('click', (ev) => {
const btn = ev.target.closest('.rv-wrap-toggle');
if (!btn) return;
ev.preventDefault();
ev.stopPropagation();
const wrap = btn.closest('.rv-diagram-wrap');
const pre = wrap?.querySelector('pre.rv-diagram');
if (!pre || !wrap) return;
const nowrap = pre.classList.toggle('rv-nowrap');
wrap.classList.toggle('rv-wrap-nowrap', nowrap);
});
}
async toggleResponseViewer() {
const viewer = document.getElementById('responseViewer');
const backdrop = document.getElementById('responseViewerBackdrop');
@@ -963,27 +1108,18 @@ class CodemanApp {
const data = await res.json();
let lastResponse = data.text || '';
// Source 2: Terminal buffer fallback (strip ANSI codes)
// Source 2: Terminal buffer fallback — strip ANSI, drop Claude CLI chrome
if (!lastResponse) {
const termRes = await fetch(`/api/sessions/${this.activeSessionId}/terminal`);
const termData = await termRes.json();
if (termData.terminalBuffer) {
lastResponse = termData.terminalBuffer
.replace(/\x1b\[\?[0-9;]*[a-zA-Z]/g, '')
.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '')
.replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '')
.replace(/\x1b[()][A-Z0-9]/g, '')
.replace(/\x1b[>=<]/g, '')
.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, '')
.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
.replace(/[ \t]+$/gm, '')
.replace(/\n{4,}/g, '\n\n\n')
.trim();
lastResponse = this._cleanTerminalBuffer(termData.terminalBuffer);
}
}
const body = document.getElementById('responseViewerBody');
body.innerHTML = this._renderMarkdown(lastResponse);
this._bindResponseViewerInteractions(body);
// Reset state for fresh open
const title = document.getElementById('responseViewerTitle');
@@ -1020,11 +1156,12 @@ class CodemanApp {
body.innerHTML = '';
for (const msg of messages) {
const div = document.createElement('div');
div.className = 'rv-message';
const isUser = msg.role === 'user';
div.className = 'rv-message ' + (isUser ? 'rv-msg-user' : 'rv-msg-assistant');
const role = document.createElement('div');
role.className = 'rv-role ' + (msg.role === 'user' ? 'rv-role-user' : 'rv-role-assistant');
role.textContent = msg.role === 'user' ? 'You' : 'Claude';
role.className = 'rv-role ' + (isUser ? 'rv-role-user' : 'rv-role-assistant');
role.textContent = isUser ? 'You' : 'Claude';
div.appendChild(role);
const text = document.createElement('div');
@@ -1034,6 +1171,7 @@ class CodemanApp {
body.appendChild(div);
}
this._bindResponseViewerInteractions(body);
if (title) title.textContent = `Conversation (${messages.length} messages)`;
if (moreBtn) moreBtn.style.display = 'none';
+12
View File
@@ -1095,6 +1095,18 @@
</label>
<span class="form-hint">Use 1M token context window (model: opus[1m]) for all new sessions</span>
</div>
<div class="form-row">
<label>Thinking Effort</label>
<select id="appSettingsThinkingEffort" class="form-select">
<option value="">Default</option>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
<option value="xhigh">XHigh</option>
<option value="max">Max</option>
</select>
<span class="form-hint">Set CLAUDE_CODE_EFFORT_LEVEL for all new sessions (default = no override)</span>
</div>
<!-- Nice Priority Section -->
<div class="form-section-header">Nice Priority</div>
<div class="form-row form-row-switch">
+5 -1
View File
@@ -92,6 +92,7 @@ const KeyboardAccessoryBar = {
</button>
<button class="accessory-btn" data-action="tab" title="Tab">Tab</button>
<button class="accessory-btn" data-action="shift-tab" title="Shift+Tab">⇧Tab</button>
<button class="accessory-btn" data-action="effort-max" title="/effort max">Max</button>
<button class="accessory-btn" data-action="ctrl-o" title="Ctrl+O">⌃O</button>
<button class="accessory-btn" data-action="opt-enter" title="Option+Enter (newline)">⌥Enter</button>
<button class="accessory-btn" data-action="esc" title="Escape">Esc</button>
@@ -125,7 +126,7 @@ const KeyboardAccessoryBar = {
this.handleAction(action, btn);
// Refocus terminal so keyboard stays open (tap blurs terminal → keyboard dismisses → toolbar shifts)
const refocusActions = new Set(['scroll-up', 'scroll-down', 'arrow-left', 'arrow-right', 'tab', 'shift-tab', 'ctrl-o', 'opt-enter', 'esc']);
const refocusActions = new Set(['scroll-up', 'scroll-down', 'arrow-left', 'arrow-right', 'tab', 'shift-tab', 'ctrl-o', 'opt-enter', 'esc', 'effort-max']);
if (refocusActions.has(action) ||
((action === 'clear' || action === 'compact') && this._confirmAction)) {
if (typeof app !== 'undefined' && app.terminal) {
@@ -184,6 +185,9 @@ const KeyboardAccessoryBar = {
case 'ctrl-o':
this.sendKey('\x0f');
break;
case 'effort-max':
this.sendCommand('/effort max');
break;
case 'init':
this.sendCommand('/init');
break;
+2 -5
View File
@@ -893,6 +893,7 @@ html.mobile-init .file-browser-panel {
gap: 8px;
align-items: center;
overflow-x: auto;
overflow-y: hidden;
-webkit-overflow-scrolling: touch;
z-index: 51;
transition: transform 0.15s ease-out;
@@ -1195,11 +1196,6 @@ html.mobile-init .file-browser-panel {
touch-action: none;
}
/* Response viewer — show eye icon in header on mobile */
.btn-response-viewer-header {
display: inline-flex !important;
}
.response-viewer {
padding-bottom: var(--safe-area-bottom, 0px);
}
@@ -2097,6 +2093,7 @@ html.mobile-init .file-browser-panel {
gap: 8px;
align-items: center;
overflow-x: auto;
overflow-y: hidden;
-webkit-overflow-scrolling: touch;
z-index: 51;
}
+5
View File
@@ -1032,6 +1032,10 @@ Object.assign(CodemanApp.prototype, {
const enabledItems = config.generatedPlan?.filter(i => i.enabled);
try {
const envOverrides = this.buildEnvOverrides(
this.getCaseSettings(config.caseName),
this.loadAppSettingsFromStorage()
);
const res = await fetch('/api/ralph-loop/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -1042,6 +1046,7 @@ Object.assign(CodemanApp.prototype, {
maxIterations: config.maxIterations || null,
enableRespawn: config.enableRespawn,
planItems: enabledItems?.length ? enabledItems : undefined,
...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}),
}),
});
const data = await res.json();
+19 -4
View File
@@ -12,6 +12,22 @@
*/
Object.assign(CodemanApp.prototype, {
/**
* Build envOverrides payload from case + global settings.
* Single source of truth for the server-side tmux setenv values.
* Keys omitted when value is default/falsy — backend treats unset as "no override".
*/
buildEnvOverrides(caseSettings, globalSettings) {
const env = {};
if (caseSettings?.agentTeams || globalSettings?.agentTeamsEnabled) {
env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS = '1';
}
if (globalSettings?.thinkingEffort) {
env.CLAUDE_CODE_EFFORT_LEVEL = globalSettings.thinkingEffort;
}
return env;
},
// ═══════════════════════════════════════════════════════════════
// Quick Start
// ═══════════════════════════════════════════════════════════════
@@ -319,10 +335,7 @@ Object.assign(CodemanApp.prototype, {
// Build env overrides from global + case settings (case overrides global)
const caseSettings = this.getCaseSettings(caseName);
const globalSettings = this.loadAppSettingsFromStorage();
const envOverrides = {};
if (caseSettings.agentTeams || globalSettings.agentTeamsEnabled) {
envOverrides.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS = '1';
}
const envOverrides = this.buildEnvOverrides(caseSettings, globalSettings);
const hasEnvOverrides = Object.keys(envOverrides).length > 0;
const useOpus1m = caseSettings.opusContext1m || globalSettings.opusContext1mEnabled;
const modelOverride = useOpus1m ? 'opus[1m]' : '';
@@ -526,6 +539,7 @@ Object.assign(CodemanApp.prototype, {
}
// Quick-start with opencode mode (auto-allow tools by default)
const envOverrides = this.buildEnvOverrides(this.getCaseSettings(caseName), this.loadAppSettingsFromStorage());
const res = await fetch('/api/quick-start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -533,6 +547,7 @@ Object.assign(CodemanApp.prototype, {
caseName,
mode: 'opencode',
openCodeConfig: { autoAllowTools: true },
...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}),
})
});
const data = await res.json();
+2
View File
@@ -334,6 +334,7 @@ Object.assign(CodemanApp.prototype, {
// Claude Permissions settings
document.getElementById('appSettingsAgentTeams').checked = settings.agentTeamsEnabled ?? false;
document.getElementById('appSettingsOpusContext1m').checked = settings.opusContext1mEnabled ?? false;
document.getElementById('appSettingsThinkingEffort').value = settings.thinkingEffort ?? '';
// CPU Priority settings
const niceSettings = settings.nice || {};
document.getElementById('appSettingsNiceEnabled').checked = niceSettings.enabled ?? false;
@@ -1134,6 +1135,7 @@ Object.assign(CodemanApp.prototype, {
// Claude Permissions settings
agentTeamsEnabled: document.getElementById('appSettingsAgentTeams').checked,
opusContext1mEnabled: document.getElementById('appSettingsOpusContext1m').checked,
thinkingEffort: document.getElementById('appSettingsThinkingEffort').value,
// CPU Priority settings
nice: {
enabled: document.getElementById('appSettingsNiceEnabled').checked,
+320 -40
View File
@@ -2202,13 +2202,10 @@ body {
.history-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.55rem 0.8rem;
flex-direction: column;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 8px;
cursor: pointer;
transition: all var(--transition-smooth);
text-align: left;
}
@@ -2219,20 +2216,37 @@ body {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
}
.history-item.expanded {
border-color: rgba(59, 130, 246, 0.35);
background: rgba(255, 255, 255, 0.05);
}
.history-item-main {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.55rem 0.8rem;
cursor: pointer;
border-radius: 8px;
}
.history-item-text {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.15rem;
gap: 0.2rem;
}
.history-item-title {
font-size: 0.8rem;
color: var(--text);
line-height: 1.35;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
word-break: break-word;
}
.history-item-subtitle {
@@ -2243,18 +2257,99 @@ body {
white-space: nowrap;
}
.history-item-subtitle.is-case {
color: #7aa7ff;
font-weight: 500;
}
.history-item-meta {
font-size: 0.7rem;
color: var(--text-muted);
white-space: nowrap;
align-self: center;
}
.history-item-size {
font-size: 0.7rem;
.history-item-expand {
appearance: none;
border: none;
background: transparent;
color: var(--text-dim);
white-space: nowrap;
min-width: 45px;
text-align: right;
font-size: 1.1rem;
line-height: 1;
padding: 0.25rem 0.4rem;
cursor: pointer;
border-radius: 6px;
transition: background var(--transition-smooth), color var(--transition-smooth);
flex-shrink: 0;
}
.history-item-expand:hover {
background: rgba(255, 255, 255, 0.08);
color: var(--text);
}
.history-item.expanded .history-item-expand {
color: #7aa7ff;
transform: rotate(90deg);
}
.history-item-detail {
padding: 0.5rem 0.8rem 0.7rem;
border-top: 1px dashed rgba(255, 255, 255, 0.08);
display: flex;
flex-direction: column;
gap: 0.4rem;
font-size: 0.72rem;
color: var(--text-muted);
}
.history-item-detail[hidden] {
display: none;
}
.history-detail-row {
display: flex;
gap: 0.5rem;
align-items: flex-start;
word-break: break-word;
}
.history-detail-label {
color: var(--text-dim);
min-width: 46px;
flex-shrink: 0;
font-size: 0.65rem;
text-transform: uppercase;
letter-spacing: 0.04em;
padding-top: 0.1rem;
}
.history-detail-value {
color: var(--text);
flex: 1;
min-width: 0;
white-space: pre-wrap;
}
.history-detail-path {
font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
font-size: 0.68rem;
color: #a8b5c9;
}
.history-detail-meta {
color: var(--text-dim);
font-size: 0.68rem;
}
@media (max-width: 640px) {
.history-item-meta {
font-size: 0.65rem;
}
.history-item-main {
gap: 0.45rem;
padding: 0.6rem 0.7rem;
}
}
.history-show-more {
@@ -7817,9 +7912,8 @@ kbd {
Response Viewer — native-scroll overlay for reading Claude responses
═══════════════════════════════════════════════════════════════ */
/* Hidden on desktop — only shown on mobile via mobile.css override */
.btn-response-viewer-header {
display: none !important;
display: inline-flex !important;
}
.response-viewer {
@@ -7885,33 +7979,54 @@ kbd {
line-height: 1;
}
/* Conversation thread messages */
/* Conversation thread messages — card-style layout for clear separation */
.rv-message {
margin-bottom: 16px;
padding-bottom: 16px;
border-bottom: 1px solid #2a2a3a;
margin: 0 0 18px;
padding: 14px 16px 16px;
border-radius: 10px;
border: 1px solid #252538;
border-left-width: 3px;
background: #181826;
position: relative;
}
.rv-message:last-child {
border-bottom: none;
margin-bottom: 0;
padding-bottom: 0;
}
.rv-role {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 6px;
}
/* Distinct accent per role so threads are scannable at a glance */
.rv-message:has(.rv-role-user),
.rv-message.rv-msg-user {
border-left-color: #7aa2ff;
background: #16182a;
}
.rv-message:has(.rv-role-assistant),
.rv-message.rv-msg-assistant {
border-left-color: #6ddb7f;
background: #161f1a;
}
.rv-role {
display: inline-block;
font-size: 10.5px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 1px;
margin-bottom: 10px;
padding: 2px 8px;
border-radius: 10px;
background: rgba(255, 255, 255, 0.04);
}
.rv-role-user {
color: #5c7cfa;
color: #7aa2ff;
background: rgba(122, 162, 255, 0.12);
}
.rv-role-assistant {
color: #51cf66;
color: #6ddb7f;
background: rgba(109, 219, 127, 0.12);
}
/* Markdown rendered content inside response viewer */
@@ -7951,6 +8066,116 @@ kbd {
border-radius: 6px;
padding: 10px 12px;
overflow-x: auto;
margin: 1em 0;
-webkit-overflow-scrolling: touch;
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.02);
position: relative;
}
/* Default: wrap long lines so mobile code reads naturally without horizontal scroll.
Preserve indentation (pre-wrap) but allow breaks inside long tokens
(URLs, paths, identifiers) so they don't overflow. */
.rv-text pre code,
.response-viewer-body > pre code {
background: none;
color: #e6e6f0;
padding: 0;
font-family: 'Fira Code', 'JetBrains Mono', 'SF Mono', Menlo, Monaco, monospace;
font-size: 12.5px;
line-height: 1.55;
white-space: pre-wrap;
word-break: normal;
overflow-wrap: anywhere;
tab-size: 4;
}
/* ASCII diagrams (box-drawing, arrows): default is wrap for mobile readability.
A toggle button lets users switch to horizontal-scroll mode when preserving
the original grid structure matters more than fitting the viewport. The
button lives in a wrapper div outside the <pre> so it stays pinned to the
visual right edge when the user scrolls horizontally. */
.rv-text .rv-diagram-wrap,
.response-viewer-body .rv-diagram-wrap {
position: relative;
margin: 1em 0;
max-width: var(--rv-content-max, 720px);
margin-left: auto;
margin-right: auto;
}
.rv-text .rv-diagram-wrap > pre.rv-diagram,
.response-viewer-body .rv-diagram-wrap > pre.rv-diagram {
/* Pre lives inside wrap — move outer spacing to wrap */
margin: 0;
padding-right: 44px; /* reserve space for the pinned button */
}
/* Default state: wrap long lines — same behavior as regular code blocks */
.rv-text pre.rv-diagram code,
.response-viewer-body pre.rv-diagram code {
white-space: pre-wrap;
overflow-wrap: anywhere;
word-break: normal;
}
/* Scroll-mode (toggled): preserve structure, horizontal scroll with gradient hint */
.rv-text pre.rv-diagram.rv-nowrap code,
.response-viewer-body pre.rv-diagram.rv-nowrap code {
white-space: pre;
overflow-wrap: normal;
word-break: normal;
}
.rv-text pre.rv-diagram.rv-nowrap,
.response-viewer-body pre.rv-diagram.rv-nowrap {
background:
linear-gradient(to left, #0f0f1a 0, rgba(15, 15, 26, 0) 28px) right / 28px 100% no-repeat,
linear-gradient(to left, rgba(122, 162, 255, 0.18) 0, rgba(15, 15, 26, 0) 28px) right / 28px 100% no-repeat,
#0f0f1a;
}
/* Toggle button — pinned to the wrapper's top-right, NOT affected by <pre>'s
horizontal scroll since it lives outside that scrolling container. */
.rv-wrap-toggle {
position: absolute;
top: 6px;
right: 6px;
width: 28px;
height: 24px;
padding: 0;
border: 1px solid #2f2f45;
border-radius: 5px;
background: rgba(20, 20, 32, 0.92);
color: #8b8b97;
font-size: 11px;
line-height: 1;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
transition: color 0.15s, border-color 0.15s;
z-index: 2;
}
.rv-wrap-toggle:hover,
.rv-wrap-toggle:active {
color: #e0e0ec;
border-color: #4a4a65;
}
/* Default icon = "return" (wrap is active). Clicking switches to expand/scroll. */
.rv-wrap-toggle::before {
content: '↵';
font-size: 13px;
}
.rv-diagram-wrap:has(> pre.rv-nowrap) .rv-wrap-toggle::before,
.rv-diagram-wrap.rv-wrap-nowrap .rv-wrap-toggle::before {
content: '⤢';
}
.rv-text ul, .rv-text ol,
.response-viewer-body > ul, .response-viewer-body > ol {
margin: 0.6em 0;
}
@@ -7986,25 +8211,80 @@ kbd {
text-decoration: none;
}
.rv-text table {
.rv-text a:hover,
.response-viewer-body a:hover {
border-bottom-color: #7aa2ff;
}
/* Tables — scroll wrapper keeps table proper while allowing horizontal overflow */
.rv-table-wrap {
margin: 1em 0;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
border: 1px solid #2a2a3d;
border-radius: 8px;
background: #12121d;
}
.rv-text table,
.response-viewer-body > table,
.rv-table-wrap > table {
border-collapse: collapse;
margin: 0.6em 0;
margin: 0;
width: 100%;
font-size: 0.9em;
font-size: 0.92em;
line-height: 1.55;
}
.rv-text th, .rv-text td {
border: 1px solid #333;
padding: 4px 8px;
.rv-text th, .rv-text td,
.response-viewer-body > table th,
.response-viewer-body > table td,
.rv-table-wrap th, .rv-table-wrap td {
border-bottom: 1px solid #252538;
border-right: 1px solid #252538;
padding: 8px 12px;
text-align: left;
vertical-align: top;
white-space: normal;
}
.rv-text th {
background: #2a2a3e;
color: #e0e0e0;
.rv-text th:last-child, .rv-text td:last-child,
.response-viewer-body > table th:last-child,
.response-viewer-body > table td:last-child,
.rv-table-wrap th:last-child, .rv-table-wrap td:last-child {
border-right: none;
}
.rv-text hr {
.rv-text tr:last-child td,
.response-viewer-body > table tr:last-child td,
.rv-table-wrap tr:last-child td {
border-bottom: none;
}
.rv-text th,
.response-viewer-body > table th,
.rv-table-wrap th {
background: #20202e;
color: #f0f0f5;
font-weight: 600;
border-bottom: 2px solid #2f2f45;
white-space: nowrap;
}
.rv-text tbody tr:nth-child(even) td,
.response-viewer-body > table tbody tr:nth-child(even) td,
.rv-table-wrap tbody tr:nth-child(even) td {
background: rgba(255, 255, 255, 0.022);
}
.rv-text tbody tr:hover td,
.response-viewer-body > table tbody tr:hover td,
.rv-table-wrap tbody tr:hover td {
background: rgba(122, 162, 255, 0.06);
}
.rv-text hr,
.response-viewer-body > hr {
border: none;
border-top: 1px solid #333;
margin: 1em 0;
+112 -13
View File
@@ -845,8 +845,40 @@ Object.assign(CodemanApp.prototype, {
return items;
},
/**
* Resolve workingDir to a case-aware short label.
* - Exact case path match → "#caseName"
* - workingDir under a case dir → "#caseName/subdir"
* - Otherwise → basename (e.g. "Claudeman")
*/
_resolveCaseLabel(workingDir, cases) {
if (!workingDir) return '';
let best = null;
for (const c of cases || []) {
if (!c || !c.path) continue;
if (workingDir === c.path) {
return `#${c.name}`;
}
if (workingDir.startsWith(c.path + '/')) {
const len = c.path.length;
if (!best || len > best.len) {
best = { name: c.name, suffix: workingDir.slice(len), len };
}
}
}
if (best) return `#${best.name}${best.suffix}`;
return workingDir.split('/').pop() || workingDir;
},
/** Normalize home prefixes to "~/" on both Linux and macOS */
_shortenHomePath(p) {
return (p || '')
.replace(/^\/home\/[^/]+\//, '~/')
.replace(/^\/Users\/[^/]+\//, '~/');
},
/** Build a single history item DOM element */
_buildHistoryItem(s) {
_buildHistoryItem(s, cases) {
const size =
s.sizeBytes < 1024
? `${s.sizeBytes}B`
@@ -858,12 +890,17 @@ Object.assign(CodemanApp.prototype, {
date.toLocaleDateString('en', { month: 'short', day: 'numeric' }) +
' ' +
date.toLocaleTimeString('en', { hour: '2-digit', minute: '2-digit', hour12: false });
const shortDir = s.workingDir.replace(/^\/home\/[^/]+\//, '~/');
const shortDir = this._shortenHomePath(s.workingDir);
const caseLabel = this._resolveCaseLabel(s.workingDir, cases);
const item = document.createElement('div');
item.className = 'history-item';
item.title = s.workingDir;
item.addEventListener('click', () => this.resumeHistorySession(s.sessionId, s.workingDir));
// Main row: clickable surface that triggers resume
const mainRow = document.createElement('div');
mainRow.className = 'history-item-main';
mainRow.addEventListener('click', () => this.resumeHistorySession(s.sessionId, s.workingDir));
const textCol = document.createElement('div');
textCol.className = 'history-item-text';
@@ -874,7 +911,8 @@ Object.assign(CodemanApp.prototype, {
const subtitleSpan = document.createElement('span');
subtitleSpan.className = 'history-item-subtitle';
subtitleSpan.textContent = shortDir;
if (caseLabel.startsWith('#')) subtitleSpan.classList.add('is-case');
subtitleSpan.textContent = caseLabel;
textCol.append(titleSpan, subtitleSpan);
@@ -882,11 +920,54 @@ Object.assign(CodemanApp.prototype, {
metaSpan.className = 'history-item-meta';
metaSpan.textContent = timeStr;
const sizeSpan = document.createElement('span');
sizeSpan.className = 'history-item-size';
sizeSpan.textContent = size;
const expandBtn = document.createElement('button');
expandBtn.className = 'history-item-expand';
expandBtn.type = 'button';
expandBtn.setAttribute('aria-label', 'Show details');
expandBtn.setAttribute('aria-expanded', 'false');
expandBtn.textContent = '⋯'; // ⋯
item.append(textCol, metaSpan, sizeSpan);
mainRow.append(textCol, metaSpan, expandBtn);
// Detail panel: full prompt + full path, hidden by default
const detail = document.createElement('div');
detail.className = 'history-item-detail';
detail.hidden = true;
const promptRow = document.createElement('div');
promptRow.className = 'history-detail-row';
const promptLabel = document.createElement('span');
promptLabel.className = 'history-detail-label';
promptLabel.textContent = 'Prompt';
const promptText = document.createElement('span');
promptText.className = 'history-detail-value history-detail-prompt';
promptText.textContent = s.firstPrompt || '(no prompt captured)';
promptRow.append(promptLabel, promptText);
const pathRow = document.createElement('div');
pathRow.className = 'history-detail-row';
const pathLabel = document.createElement('span');
pathLabel.className = 'history-detail-label';
pathLabel.textContent = 'Path';
const pathText = document.createElement('span');
pathText.className = 'history-detail-value history-detail-path';
pathText.textContent = shortDir;
pathRow.append(pathLabel, pathText);
const metaRow = document.createElement('div');
metaRow.className = 'history-detail-row history-detail-meta';
metaRow.textContent = `${timeStr} · ${size} · ${s.sessionId.slice(0, 8)}`;
detail.append(promptRow, pathRow, metaRow);
expandBtn.addEventListener('click', (ev) => {
ev.stopPropagation();
const expanded = item.classList.toggle('expanded');
detail.hidden = !expanded;
expandBtn.setAttribute('aria-expanded', expanded ? 'true' : 'false');
});
item.append(mainRow, detail);
return item;
},
@@ -899,7 +980,15 @@ Object.assign(CodemanApp.prototype, {
if (!container || !list) return;
try {
const allSessions = await this._fetchHistorySessions(30);
// Load cases in parallel so subtitle can show "#caseName" labels.
// Prefer already-loaded this.cases to avoid an extra request.
const casesPromise = Array.isArray(this.cases) && this.cases.length > 0
? Promise.resolve(this.cases)
: fetch('/api/cases').then((r) => (r.ok ? r.json() : [])).catch(() => []);
const [allSessions, cases] = await Promise.all([
this._fetchHistorySessions(30),
casesPromise,
]);
if (allSessions.length === 0) {
container.style.display = 'none';
return;
@@ -910,7 +999,7 @@ Object.assign(CodemanApp.prototype, {
// Render initial items
for (let i = 0; i < Math.min(initialCount, allSessions.length); i++) {
list.appendChild(this._buildHistoryItem(allSessions[i]));
list.appendChild(this._buildHistoryItem(allSessions[i], cases));
}
// Add "Show More" button if there are more items
@@ -920,7 +1009,7 @@ Object.assign(CodemanApp.prototype, {
moreBtn.textContent = `Show ${allSessions.length - initialCount} more`;
moreBtn.addEventListener('click', () => {
for (let i = initialCount; i < allSessions.length; i++) {
list.insertBefore(this._buildHistoryItem(allSessions[i]), moreBtn);
list.insertBefore(this._buildHistoryItem(allSessions[i], cases), moreBtn);
}
moreBtn.remove();
});
@@ -953,11 +1042,21 @@ Object.assign(CodemanApp.prototype, {
}
const name = `w${startNumber}-${dirName}`;
// Create session with resumeSessionId
// Create session with resumeSessionId — include envOverrides so resumed
// conversations inherit current UI settings (effort, agent teams, etc.).
// Match by path (not basename) so linked/renamed cases still resolve correctly.
const matchingCase = (this.cases || []).find((c) => c.path === workingDir);
const caseName = matchingCase?.name || workingDir.split('/').pop() || '';
const envOverrides = this.buildEnvOverrides(this.getCaseSettings(caseName), this.loadAppSettingsFromStorage());
const createRes = await fetch('/api/sessions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workingDir, name, resumeSessionId: sessionId }),
body: JSON.stringify({
workingDir,
name,
resumeSessionId: sessionId,
...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}),
}),
});
const createData = await createRes.json();
if (!createData.success) throw new Error(createData.error);
+8
View File
@@ -43,6 +43,14 @@ export function registerHookEventRoutes(
}
}
// Sync Claude's current conversation id. Interactive PTY mode never emits
// `session_id` on stdout, so hooks are the only reliable way to learn that
// the user ran `/clear` (which spins up a new conversation jsonl).
if (data && typeof data.session_id === 'string' && data.session_id) {
const session = ctx.sessions.get(sessionId);
session?.adoptClaudeSessionId(data.session_id);
}
// Sanitize forwarded data: only include known safe fields, limit size
const safeData = sanitizeHookData(data);
ctx.broadcast(`hook:${event}`, { sessionId, timestamp: Date.now(), ...safeData });
+9 -5
View File
@@ -14,7 +14,7 @@ import { RespawnController } from '../../respawn-controller.js';
import { RalphConfigSchema, FixPlanImportSchema, RalphPromptWriteSchema, RalphLoopStartSchema } from '../schemas.js';
import { SseEvent } from '../sse-events.js';
import { autoConfigureRalph, CASES_DIR, SETTINGS_PATH, findSessionOrFail, parseBody } from '../route-helpers.js';
import { writeHooksConfig } from '../../hooks-config.js';
import { writeHooksConfig, stripCaseEnvKeys } from '../../hooks-config.js';
import { generateClaudeMd } from '../../templates/claude-md.js';
import { getLifecycleLog } from '../../session-lifecycle-log.js';
import type { SessionPort, EventPort, RespawnPort, ConfigPort, InfraPort } from '../ports/index.js';
@@ -268,10 +268,8 @@ export function registerRalphRoutes(
);
}
const { caseName, taskDescription, completionPhrase, maxIterations, enableRespawn, planItems } = parseBody(
RalphLoopStartSchema,
req.body
);
const { caseName, taskDescription, completionPhrase, maxIterations, enableRespawn, planItems, envOverrides } =
parseBody(RalphLoopStartSchema, req.body);
const casePath = join(CASES_DIR, caseName);
@@ -298,6 +296,11 @@ export function registerRalphRoutes(
}
}
// Strip stale disk entries for keys this request is actively setting.
if (envOverrides && Object.keys(envOverrides).length > 0) {
await stripCaseEnvKeys(casePath, Object.keys(envOverrides));
}
// Create session
const niceConfig = await ctx.getGlobalNiceConfig();
const rlModelConfig = await ctx.getModelConfig();
@@ -311,6 +314,7 @@ export function registerRalphRoutes(
model: rlModelConfig?.defaultModel || undefined,
claudeMode: rlClaudeModeConfig.claudeMode,
allowedTools: rlClaudeModeConfig.allowedTools,
envOverrides,
});
// Configure Ralph tracker
+123 -10
View File
@@ -44,7 +44,7 @@ import {
validatePathWithinBase,
} from '../route-helpers.js';
import { AUTH_COOKIE_NAME } from '../middleware/auth.js';
import { writeHooksConfig, updateCaseEnvVars, updateCaseModel } from '../../hooks-config.js';
import { writeHooksConfig, updateCaseModel, stripCaseEnvKeys } from '../../hooks-config.js';
import { generateClaudeMd } from '../../templates/claude-md.js';
import { imageWatcher } from '../../image-watcher.js';
import { getLifecycleLog } from '../../session-lifecycle-log.js';
@@ -166,9 +166,21 @@ export function registerSessionRoutes(
}
}
// Write env overrides to .claude/settings.local.json if provided
if (body.envOverrides && Object.keys(body.envOverrides).length > 0) {
await updateCaseEnvVars(workingDir, body.envOverrides);
// envOverrides flow through Session → tmux setenv (ephemeral, per-session).
//
// For keys the caller is actively setting, strip any stale disk entry a prior
// Codeman version may have written. Scope limited to:
// - Claude mode (OpenCode doesn't read .claude/settings.local.json)
// - workingDir inside CASES_DIR (Codeman's managed territory — we never mutate
// .claude/settings.local.json in arbitrary user repos that POST /api/sessions
// can target, because those may have hand-authored values).
const canStripDisk =
body.mode !== 'opencode' &&
body.envOverrides &&
Object.keys(body.envOverrides).length > 0 &&
workingDir.startsWith(CASES_DIR + '/');
if (canStripDisk) {
await stripCaseEnvKeys(workingDir, Object.keys(body.envOverrides!));
}
// Write model override to .claude/settings.local.json if provided
@@ -239,6 +251,7 @@ export function registerSessionRoutes(
allowedTools: claudeModeConfig.allowedTools,
openCodeConfig: mode === 'opencode' ? body.openCodeConfig : undefined,
resumeSessionId: validatedResumeId,
envOverrides: body.envOverrides,
});
ctx.addSession(session);
@@ -582,15 +595,99 @@ export function registerSessionRoutes(
// ========== Get Last Response (from transcript JSONL) ==========
// Resolves the most recent Claude conversation id for a session's cwd by
// tailing ~/.claude/history.jsonl. After `/clear`, Claude Code keeps writing
// to a new <uuid>.jsonl; history.jsonl is the only source-of-truth update
// that does not rely on project-local hooks (we intentionally don't install
// hooks in arbitrary user repos, see the POST /api/sessions comment).
//
// Entries from OTHER Codeman sessions in the same cwd are filtered out by
// their known claudeSessionIds so concurrent tabs don't shadow each other,
// as long as each has had its id resolved at least once.
async function resolveActiveClaudeSessionIdFromHistory(
session: Session,
projectsDir: string
): Promise<string | null> {
const historyPath = join(homedir(), '.claude', 'history.jsonl');
const otherClaudeIds = new Set<string>();
for (const s of ctx.sessions.values()) {
if (s.id !== session.id && s.workingDir === session.workingDir && s.claudeSessionId) {
otherClaudeIds.add(s.claudeSessionId);
}
}
let candidateSid: string | null = null;
try {
const content = await fs.readFile(historyPath, 'utf8');
const lines = content.split('\n');
for (let i = lines.length - 1; i >= 0; i--) {
const line = lines[i];
if (!line) continue;
try {
const entry = JSON.parse(line) as { project?: string; sessionId?: string };
if (
entry.project === session.workingDir &&
typeof entry.sessionId === 'string' &&
!otherClaudeIds.has(entry.sessionId)
) {
candidateSid = entry.sessionId;
break;
}
} catch {
// Skip unparseable lines
}
}
} catch {
return null;
}
if (!candidateSid || candidateSid === session.id) return candidateSid;
// Safety: only adopt if the candidate's jsonl is more recently written
// than our initial conversation's jsonl. Blocks stale ids inherited from
// a prior Codeman session that happened to share this cwd.
try {
const projectDirs = await fs.readdir(projectsDir);
let candidateMtime = 0;
let initialMtime = 0;
for (const projDir of projectDirs) {
try {
const cs = await fs.stat(join(projectsDir, projDir, `${candidateSid}.jsonl`));
if (cs.mtimeMs > candidateMtime) candidateMtime = cs.mtimeMs;
} catch {
/* not in this dir */
}
try {
const is = await fs.stat(join(projectsDir, projDir, `${session.id}.jsonl`));
if (is.mtimeMs > initialMtime) initialMtime = is.mtimeMs;
} catch {
/* not in this dir */
}
}
if (candidateMtime === 0) return null;
if (initialMtime > 0 && candidateMtime <= initialMtime) return null;
} catch {
return null;
}
return candidateSid;
}
app.get('/api/sessions/:id/last-response', async (req) => {
const { id } = req.params as { id: string };
const session = findSessionOrFail(ctx, id);
// The Claude conversation ID (used as JSONL filename)
const claudeSessionId = session.claudeSessionId || session.id;
// Scan ~/.claude/projects/*/ for the transcript file
const projectsDir = join(process.env.HOME || '/tmp', '.claude', 'projects');
// Adopt the current conversation id if the user ran `/clear` — Claude CLI's
// interactive PTY emits no JSON on stdout, so without this lookup the
// stored id stays pinned to the pre-/clear transcript.
const activeId = await resolveActiveClaudeSessionIdFromHistory(session, projectsDir);
if (activeId && activeId !== session.claudeSessionId) {
session.adoptClaudeSessionId(activeId);
}
// The Claude conversation ID (used as JSONL filename)
const claudeSessionId = session.claudeSessionId || session.id;
let transcriptText = '';
let transcriptTimestamp = '';
@@ -854,7 +951,11 @@ export function registerSessionRoutes(
);
}
const { prompt, workingDir } = parseBody(QuickRunSchema, req.body, 'Invalid request body');
const {
prompt,
workingDir,
envOverrides: runEnvOverrides,
} = parseBody(QuickRunSchema, req.body, 'Invalid request body');
if (!prompt.trim()) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'prompt is required');
@@ -873,7 +974,7 @@ export function registerSessionRoutes(
}
}
const session = new Session({ workingDir: dir });
const session = new Session({ workingDir: dir, envOverrides: runEnvOverrides });
ctx.addSession(session);
ctx.store.incrementSessionsCreated();
ctx.persistSessionState(session);
@@ -910,7 +1011,12 @@ export function registerSessionRoutes(
);
}
const { caseName = 'testcase', mode = 'claude', openCodeConfig } = parseBody(QuickStartSchema, req.body);
const {
caseName = 'testcase',
mode = 'claude',
openCodeConfig,
envOverrides,
} = parseBody(QuickStartSchema, req.body);
// Check OpenCode availability if requested
if (mode === 'opencode') {
@@ -962,6 +1068,12 @@ export function registerSessionRoutes(
}
}
// Strip stale disk entries for keys this request is actively setting (Claude only —
// see POST /api/sessions for full rationale).
if (mode !== 'opencode' && envOverrides && Object.keys(envOverrides).length > 0) {
await stripCaseEnvKeys(casePath, Object.keys(envOverrides));
}
// Create a new session with the case as working directory
// Apply global Nice priority config and model config from settings
const niceConfig = await ctx.getGlobalNiceConfig();
@@ -983,6 +1095,7 @@ export function registerSessionRoutes(
claudeMode: qsClaudeModeConfig.claudeMode,
allowedTools: qsClaudeModeConfig.allowedTools,
openCodeConfig: mode === 'opencode' ? openCodeConfig : undefined,
envOverrides,
});
// Auto-detect completion phrase from CLAUDE.md BEFORE broadcasting
+5 -42
View File
@@ -178,6 +178,7 @@ export const QuickStartSchema = z.object({
.optional(),
mode: z.enum(['claude', 'shell', 'opencode']).optional(),
openCodeConfig: OpenCodeConfigSchema,
envOverrides: safeEnvOverridesSchema,
});
// ========== Hook Events ==========
@@ -267,6 +268,8 @@ export const SettingsUpdateSchema = z
tunnelEnabled: z.boolean().optional(),
tabTwoRows: z.boolean().optional(),
agentTeamsEnabled: z.boolean().optional(),
opusContext1mEnabled: z.boolean().optional(),
thinkingEffort: z.string().max(20).optional(),
// UI visibility
showFontControls: z.boolean().optional(),
showSystemStats: z.boolean().optional(),
@@ -416,6 +419,7 @@ export const FlickerFilterSchema = z.object({
export const QuickRunSchema = z.object({
prompt: z.string().min(1).max(100000),
workingDir: safePathSchema.optional(),
envOverrides: safeEnvOverridesSchema,
});
/** POST /api/scheduled */
@@ -538,6 +542,7 @@ export const RalphLoopStartSchema = z.object({
completionPhrase: z.string().max(100).default('COMPLETE'),
maxIterations: z.number().int().min(0).max(1000).nullable().default(10),
enableRespawn: z.boolean().default(false),
envOverrides: safeEnvOverridesSchema,
planItems: z
.array(
z.object({
@@ -549,45 +554,6 @@ export const RalphLoopStartSchema = z.object({
.optional(),
});
// ========== Inferred Types ==========
export type CreateSessionInput = z.infer<typeof CreateSessionSchema>;
export type RunPromptInput = z.infer<typeof RunPromptSchema>;
export type ResizeInput = z.infer<typeof ResizeSchema>;
export type CreateCaseInput = z.infer<typeof CreateCaseSchema>;
export type QuickStartInput = z.infer<typeof QuickStartSchema>;
export type HookEventInput = z.infer<typeof HookEventSchema>;
export type RespawnConfigInput = z.infer<typeof RespawnConfigSchema>;
export type ConfigUpdateInput = z.infer<typeof ConfigUpdateSchema>;
export type SettingsUpdateInput = z.infer<typeof SettingsUpdateSchema>;
export type SessionInputWithLimitInput = z.infer<typeof SessionInputWithLimitSchema>;
export type SessionNameInput = z.infer<typeof SessionNameSchema>;
export type SessionColorInput = z.infer<typeof SessionColorSchema>;
export type RalphConfigInput = z.infer<typeof RalphConfigSchema>;
export type FixPlanImportInput = z.infer<typeof FixPlanImportSchema>;
export type RalphPromptWriteInput = z.infer<typeof RalphPromptWriteSchema>;
export type AutoClearInput = z.infer<typeof AutoClearSchema>;
export type AutoCompactInput = z.infer<typeof AutoCompactSchema>;
export type ImageWatcherInput = z.infer<typeof ImageWatcherSchema>;
export type FlickerFilterInput = z.infer<typeof FlickerFilterSchema>;
export type QuickRunInput = z.infer<typeof QuickRunSchema>;
export type ScheduledRunInput = z.infer<typeof ScheduledRunSchema>;
export type LinkCaseInput = z.infer<typeof LinkCaseSchema>;
export type GeneratePlanInput = z.infer<typeof GeneratePlanSchema>;
export type GeneratePlanDetailedInput = z.infer<typeof GeneratePlanDetailedSchema>;
export type CancelPlanInput = z.infer<typeof CancelPlanSchema>;
export type PlanTaskUpdateInput = z.infer<typeof PlanTaskUpdateSchema>;
export type PlanTaskAddInput = z.infer<typeof PlanTaskAddSchema>;
export type CpuLimitInput = z.infer<typeof CpuLimitSchema>;
export type ModelConfigUpdateInput = z.infer<typeof ModelConfigUpdateSchema>;
export type SubagentWindowStatesInput = z.infer<typeof SubagentWindowStatesSchema>;
export type SubagentParentMapInput = z.infer<typeof SubagentParentMapSchema>;
export type InteractiveRespawnInput = z.infer<typeof InteractiveRespawnSchema>;
export type RespawnEnableInput = z.infer<typeof RespawnEnableSchema>;
export type PushSubscribeInput = z.infer<typeof PushSubscribeSchema>;
export type PushPreferencesUpdateInput = z.infer<typeof PushPreferencesUpdateSchema>;
export type RalphLoopStartInput = z.infer<typeof RalphLoopStartSchema>;
// ========== Orchestrator Loop ==========
/** POST /api/orchestrator/start */
@@ -612,6 +578,3 @@ export const OrchestratorStartSchema = z.object({
export const OrchestratorRejectSchema = z.object({
feedback: z.string().min(1).max(10000),
});
export type OrchestratorStartInput = z.infer<typeof OrchestratorStartSchema>;
export type OrchestratorRejectInput = z.infer<typeof OrchestratorRejectSchema>;
+10 -2
View File
@@ -40,7 +40,7 @@ import { execSync } from 'node:child_process';
import { homedir } from 'node:os';
import { EventEmitter } from 'node:events';
import { Session, type BackgroundTask } from '../session.js';
import type { ClaudeMode } from '../types.js';
import type { ClaudeMode, SessionState } from '../types.js';
import { RespawnController, RespawnConfig } from '../respawn-controller.js';
import type { TerminalMultiplexer } from '../mux-interface.js';
import { createMultiplexer } from '../mux-factory.js';
@@ -731,7 +731,11 @@ export class WebServer extends EventEmitter {
/** Persists full session state including respawn config to state.json */
private _persistSessionStateNow(session: Session): void {
const state = session.toState();
// See session-manager.updateSessionState: __envOverrides is an internal disk-only
// field kept off SessionState to avoid leaking via API broadcasts.
const base = session.toState();
const envOverrides = session.getEnvOverridesForPersist();
const state = (envOverrides ? { ...base, __envOverrides: envOverrides } : base) as SessionState;
const controller = this.respawnControllers.get(session.id);
if (controller) {
const config = controller.getConfig();
@@ -1631,6 +1635,9 @@ export class WebServer extends EventEmitter {
// Create a session object for this mux session
const recoveryClaudeMode = await this.getClaudeModeConfig();
// Recover envOverrides from the internal __envOverrides field written by
// session-manager (see updateSessionState). Cast to read the non-public field.
const savedEnvOverrides = (savedState as { __envOverrides?: Record<string, string> })?.__envOverrides;
const session = new Session({
id: muxSession.sessionId, // Preserve the original session ID
workingDir: muxSession.workingDir,
@@ -1641,6 +1648,7 @@ export class WebServer extends EventEmitter {
muxSession: muxSession, // Pass the existing session so startInteractive() can attach to it
claudeMode: recoveryClaudeMode.claudeMode,
allowedTools: recoveryClaudeMode.allowedTools,
envOverrides: savedEnvOverrides,
});
// Update session name if it was a "Restored:" placeholder or doesn't match saved name
+1 -1
View File
@@ -58,7 +58,7 @@ export interface SessionListenerRefs {
}
/** Dependencies injected by WebServer — keeps listener creation decoupled from server internals. */
export interface SessionListenerDeps {
interface SessionListenerDeps {
broadcast(event: string, data: unknown): void;
batchTerminalData(sessionId: string, data: string): void;
batchTaskUpdate(sessionId: string, task: BackgroundTask): void;
+1 -1
View File
@@ -35,7 +35,7 @@ import {
const SSE_PADDING = ':' + 'p'.repeat(SSE_PADDING_SIZE) + '\n';
/** Dependencies injected by WebServer — keeps SseStreamManager decoupled from session/respawn state. */
export interface SseStreamManagerDeps {
interface SseStreamManagerDeps {
/** Get session state with respawn info for session:updated broadcasts */
getSessionStateWithRespawn(sessionId: string): unknown;
}
-241
View File
@@ -1,241 +0,0 @@
/**
* Input echo test: Verifies that typing in the web UI doesn't produce
* "visible below" artifacts (text mirrored onto Ink's status bar rows).
*
* Test approach:
* 1. Creates a SHELL session (bash, not Claude) to avoid Ink complexity
* 2. Types various input patterns and captures terminal state
* 3. Verifies echo appears at correct cursor position, not duplicated
*
* Usage: node test/input-echo-test.mjs [--live]
* --live Use existing sessions instead of creating new ones
*/
import { chromium } from 'playwright';
const PORT = 3000;
const BASE = `http://localhost:${PORT}`;
async function createShellSession() {
const resp = await fetch(`${BASE}/api/sessions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mode: 'shell', name: 'input-test' })
});
if (!resp.ok) throw new Error(`Failed to create session: ${resp.status}`);
const data = await resp.json();
return data.session?.id || data.id;
}
async function deleteSession(id) {
await fetch(`${BASE}/api/sessions/${id}`, { method: 'DELETE' }).catch(() => {});
}
async function waitForSessionReady(sessionId, timeoutMs = 10000) {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
const resp = await fetch(`${BASE}/api/sessions/${sessionId}`);
if (resp.ok) {
const data = await resp.json();
if (data.status === 'idle' || data.status === 'busy') return true;
}
await new Promise(r => setTimeout(r, 200));
}
return false;
}
async function getTerminalContent(page) {
return page.evaluate(() => {
const app = window.app;
if (!app?.terminal) return null;
const buf = app.terminal.buffer.active;
const lines = [];
for (let i = 0; i <= buf.baseY + buf.viewportY + app.terminal.rows; i++) {
const line = buf.getLine(i);
if (line) lines.push(line.translateToString(true));
}
return {
lines,
cursorX: buf.cursorX,
cursorY: buf.cursorY,
rows: app.terminal.rows,
cols: app.terminal.cols,
};
});
}
async function main() {
const isLive = process.argv.includes('--live');
let sessionId = null;
console.log('=== Input Echo Test ===\n');
// Create a shell session for testing
if (!isLive) {
console.log('Creating shell session...');
sessionId = await createShellSession();
console.log(`Session created: ${sessionId}`);
const ready = await waitForSessionReady(sessionId);
if (!ready) {
console.error('Session failed to become ready');
await deleteSession(sessionId);
process.exit(1);
}
console.log('Session ready\n');
}
const browser = await chromium.launch({
headless: true,
args: ['--window-size=1280,800']
});
const context = await browser.newContext({ viewport: { width: 1280, height: 800 } });
const page = await context.newPage();
// Collect console output for debugging
const consoleLogs = [];
page.on('console', msg => consoleLogs.push(`[${msg.type()}] ${msg.text()}`));
try {
console.log('Opening Codeman...');
await page.goto(BASE);
await page.waitForSelector('.xterm-screen', { timeout: 10000 });
await page.waitForTimeout(2000);
// Switch to our test session if not live
if (sessionId) {
// Click the test session tab
const tabClicked = await page.evaluate((id) => {
const tabs = document.querySelectorAll('.session-tab');
for (const tab of tabs) {
if (tab.dataset.sessionId === id) {
tab.click();
return true;
}
}
return false;
}, sessionId);
if (tabClicked) {
await page.waitForTimeout(1000);
console.log('Switched to test session');
}
}
// Focus the terminal
await page.click('.xterm-screen');
await page.waitForTimeout(500);
// Take before screenshot
await page.screenshot({ path: '/tmp/input-test-before.png' });
console.log('Screenshot: /tmp/input-test-before.png (before typing)');
// Get terminal state before typing
const beforeState = await getTerminalContent(page);
const beforeCursorY = beforeState?.cursorY ?? -1;
console.log(`Before: cursor at row ${beforeCursorY}, col ${beforeState?.cursorX}`);
// TEST 1: Type a short string
console.log('\n--- Test 1: Short string ---');
const testStr = 'echo hello-world-test-123';
await page.keyboard.type(testStr, { delay: 30 });
await page.waitForTimeout(600); // Wait for PTY echo round-trip
const afterShort = await getTerminalContent(page);
await page.screenshot({ path: '/tmp/input-test-short.png' });
console.log('Screenshot: /tmp/input-test-short.png');
// Check: cursor should still be on same row (or +1 for wrap)
// and text should appear at/near the cursor position
const cursorRowAfter = afterShort?.cursorY ?? -1;
console.log(`After: cursor at row ${cursorRowAfter}, col ${afterShort?.cursorX}`);
// The echo should be on the cursor's row, not below
const echoLine = afterShort?.lines?.[afterShort.cursorY + (afterShort.lines.length - afterShort.rows)] ?? '';
console.log(`Echo line: "${echoLine.trim()}"`);
if (echoLine.includes(testStr)) {
console.log('PASS: Echo found at cursor row');
} else {
console.log('INFO: Echo may be on a different row (check screenshots)');
}
// TEST 2: Press Enter and check response
console.log('\n--- Test 2: Enter key ---');
await page.keyboard.press('Enter');
await page.waitForTimeout(1000);
const afterEnter = await getTerminalContent(page);
await page.screenshot({ path: '/tmp/input-test-enter.png' });
console.log('Screenshot: /tmp/input-test-enter.png');
// Check for "hello-world-test-123" in the output
const allText = afterEnter?.lines?.join('\n') ?? '';
if (allText.includes('hello-world-test-123')) {
console.log('PASS: Command output visible');
} else {
console.log('WARN: Command output not found');
}
// TEST 3: Type a LONG string that approaches right edge
console.log('\n--- Test 3: Long string (near right edge) ---');
const longStr = 'echo ' + 'A'.repeat(afterShort?.cols ? afterShort.cols - 10 : 150);
await page.keyboard.type(longStr, { delay: 10 });
await page.waitForTimeout(800);
const afterLong = await getTerminalContent(page);
await page.screenshot({ path: '/tmp/input-test-long.png' });
console.log('Screenshot: /tmp/input-test-long.png');
console.log(`After long: cursor at row ${afterLong?.cursorY}, col ${afterLong?.cursorX}`);
// Check that no text leaked below the cursor row
const cursorAbsRow = (afterLong?.lines?.length ?? 0) - (afterLong?.rows ?? 0) + (afterLong?.cursorY ?? 0);
const rowsBelow = afterLong?.lines?.slice(cursorAbsRow + 1) ?? [];
const leakedText = rowsBelow.filter(l => l.trim().length > 0 && l.includes('AAA'));
if (leakedText.length === 0) {
console.log('PASS: No text leaked below cursor row');
} else {
console.log(`FAIL: Text leaked below cursor: ${leakedText.map(l => `"${l.trim()}"`).join(', ')}`);
}
// TEST 4: Rapid typing (paste-like)
console.log('\n--- Test 4: Rapid input (paste simulation) ---');
await page.keyboard.press('Enter');
await page.waitForTimeout(500);
// Type rapidly (no delay between chars)
await page.keyboard.type('echo rapid-test-no-delay-abcdefghij', { delay: 0 });
await page.waitForTimeout(800);
await page.screenshot({ path: '/tmp/input-test-rapid.png' });
console.log('Screenshot: /tmp/input-test-rapid.png');
await page.keyboard.press('Enter');
await page.waitForTimeout(1000);
const afterRapid = await getTerminalContent(page);
const rapidText = afterRapid?.lines?.join('\n') ?? '';
if (rapidText.includes('rapid-test-no-delay-abcdefghij')) {
console.log('PASS: Rapid input command output visible');
} else {
console.log('WARN: Rapid input output not found (check screenshots)');
}
// Final screenshot
await page.screenshot({ path: '/tmp/input-test-final.png' });
console.log('\nScreenshot: /tmp/input-test-final.png (final state)');
// Summary
console.log('\n=== Test Complete ===');
console.log('Check screenshots in /tmp/input-test-*.png');
console.log('Key check: no text should appear below the active cursor row');
} finally {
await browser.close();
if (sessionId && !isLive) {
console.log(`\nCleaning up session ${sessionId}...`);
await deleteSession(sessionId);
}
}
}
main().catch(err => {
console.error('Test failed:', err);
process.exit(1);
});
-590
View File
@@ -1,590 +0,0 @@
/**
* Local Echo Overlay — Basic operation tests.
*
* Tests the most fundamental user scenarios:
* 1. Type → switch tab → come back → backspace works
* 2. Type → switch tab → come back → space doesn't erase text
* 3. Type → switch tab → come back → backspace + new text works
* 4. Overlay position stays consistent across renders
* 5. Overlay doesn't shift left during Ink redraws
* 6. Type → switch → back → rapid backspace+retype
* 7. Overlay covers flushed canvas text properly
*
* Usage: node test/local-echo-basic-test.mjs
*/
import { chromium } from 'playwright';
const PORT = 3000;
const BASE = `http://localhost:${PORT}`;
const SCREENSHOT_DIR = '/tmp/local-echo-basic';
async function createSession(name) {
const resp = await fetch(`${BASE}/api/sessions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mode: 'claude', name })
});
if (!resp.ok) throw new Error(`Failed to create '${name}': ${resp.status}`);
const data = await resp.json();
return data.session?.id || data.id;
}
async function deleteSession(id) {
await fetch(`${BASE}/api/sessions/${id}`, { method: 'DELETE' }).catch(() => {});
}
async function waitForReady(id, ms = 15000) {
const start = Date.now();
while (Date.now() - start < ms) {
const r = await fetch(`${BASE}/api/sessions/${id}`);
if (r.ok) { const d = await r.json(); if (d.status === 'idle' || d.status === 'busy') return true; }
await new Promise(r => setTimeout(r, 300));
}
return false;
}
async function getState(page) {
return page.evaluate(() => {
const app = window.app;
if (!app) return null;
const ov = app._localEchoOverlay;
const buf = app.terminal?.buffer?.active;
return {
visible: ov?.overlay?.style.display !== 'none',
text: ov?.pendingText || '',
flushed: ov?._flushedOffset || 0,
flushedMap: app._flushedOffsets ? Object.fromEntries(app._flushedOffsets) : {},
enabled: app._localEchoEnabled,
activeId: app.activeSessionId,
// Overlay position and content
overlayLeft: ov?.overlay?.style.left,
overlayTop: ov?.overlay?.style.top,
children: (() => {
if (!ov?.overlay) return [];
return Array.from(ov.overlay.children).map(c => ({
text: Array.from(c.querySelectorAll('span')).map(s => s.textContent).join(''),
left: c.style.left,
top: c.style.top,
width: c.style.width,
bgColor: c.style.backgroundColor,
spanCount: c.querySelectorAll('span').length,
}));
})(),
// Prompt line from buffer
promptLine: (() => {
if (!buf) return null;
for (let row = app.terminal.rows - 1; row >= 0; row--) {
const line = buf.getLine(buf.viewportY + row);
if (!line) continue;
const text = line.translateToString(true);
if (text.includes('\u276f')) return { text, row, col: text.lastIndexOf('\u276f') };
}
return null;
})(),
};
});
}
async function focus(page) {
await page.evaluate(() => document.querySelector('.xterm-helper-textarea')?.focus());
await page.waitForTimeout(200);
}
async function switchTab(page, id) {
await page.evaluate((id) => document.querySelector(`.session-tab[data-id="${id}"]`)?.click(), id);
await page.waitForTimeout(1500);
}
async function waitForPrompt(page, timeoutMs = 10000) {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
const state = await getState(page);
if (state?.promptLine) return true;
await page.waitForTimeout(200);
}
return false;
}
async function sc(page, name) {
const path = `${SCREENSHOT_DIR}/${name}.png`;
await page.screenshot({ path });
console.log(` 📸 ${path}`);
}
const R = [];
const pass = (n) => { R.push({n, s:'PASS'}); console.log(` ✅ ${n}`); };
const fail = (n, d) => { R.push({n, s:'FAIL', d}); console.log(` ❌ ${n} — ${d}`); };
async function main() {
const { mkdirSync } = await import('fs');
try { mkdirSync(SCREENSHOT_DIR, { recursive: true }); } catch {}
console.log('=== Local Echo — Basic Operation Tests ===\n');
const sA = await createSession('basic-A');
const sB = await createSession('basic-B');
console.log(` A: ${sA}\n B: ${sB}`);
if (!await waitForReady(sA) || !await waitForReady(sB)) {
await deleteSession(sA); await deleteSession(sB);
throw new Error('Sessions not ready');
}
console.log(' Ready\n');
const browser = await chromium.launch({ headless: true });
const ctx = await browser.newContext({
viewport: { width: 390, height: 844 },
userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0)',
isMobile: true, hasTouch: true,
});
const page = await ctx.newPage();
// Capture JS errors
const jsErrors = [];
page.on('pageerror', err => jsErrors.push(err.message));
try {
await page.goto(BASE, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('.xterm-screen', { timeout: 10000 });
await page.waitForTimeout(2000);
// Enable local echo
await page.evaluate(() => {
const s = JSON.parse(localStorage.getItem('codeman-app-settings') || '{}');
s.localEchoEnabled = true;
localStorage.setItem('codeman-app-settings', JSON.stringify(s));
window.app?._updateLocalEchoState?.();
});
// Select tab A, wait for prompt
await switchTab(page, sA);
await focus(page);
if (!await waitForPrompt(page)) throw new Error('No prompt on A');
// ══════════════════════════════════════════════════════════════
// TEST 1: Type → switch → back → IMMEDIATE backspace
// ══════════════════════════════════════════════════════════════
console.log('--- 1. Type → switch → back → immediate backspace ---');
await page.keyboard.type('hello world', { delay: 30 });
await page.waitForTimeout(300);
let st = await getState(page);
console.log(` Typed: text="${st?.text}"`);
if (st?.text === 'hello world') pass('1a: Text buffered');
else fail('1a: Text buffered', `"${st?.text}"`);
await sc(page, '01-typed');
// Switch to B and back
await switchTab(page, sB);
st = await getState(page);
console.log(` On B: text="${st?.text}", flushedMap=${JSON.stringify(st?.flushedMap)}`);
await switchTab(page, sA);
await focus(page);
// Check state BEFORE doing anything — flushed offset should be restored
st = await getState(page);
console.log(` Back on A: text="${st?.text}", flushed=${st?.flushed}`);
if (st?.flushed === 11) pass('1b: Flushed offset restored');
else fail('1b: Flushed offset', `expected 11, got ${st?.flushed}`);
// IMMEDIATELY backspace (don't wait for buffer load)
await page.keyboard.press('Backspace');
await page.waitForTimeout(300);
st = await getState(page);
console.log(` After 1 backspace: text="${st?.text}", flushed=${st?.flushed}`);
if (st?.flushed === 10) pass('1c: Backspace decremented flushed');
else fail('1c: Backspace', `expected flushed=10, got ${st?.flushed}`);
await sc(page, '02-after-backspace');
// Backspace 5 more times
for (let i = 0; i < 5; i++) {
await page.keyboard.press('Backspace');
await page.waitForTimeout(50);
}
await page.waitForTimeout(300);
st = await getState(page);
console.log(` After 6 total backspaces: flushed=${st?.flushed}`);
if (st?.flushed === 5) pass('1d: Multiple backspaces work');
else fail('1d: Multiple backspaces', `expected flushed=5, got ${st?.flushed}`);
// Clean up
await page.keyboard.press('Control+c');
await page.waitForTimeout(500);
// ══════════════════════════════════════════════════════════════
// TEST 2: Type → switch → back → IMMEDIATE space (don't erase)
// ══════════════════════════════════════════════════════════════
console.log('\n--- 2. Type → switch → back → immediate space ---');
await focus(page);
await waitForPrompt(page);
await page.keyboard.type('test input', { delay: 30 });
await page.waitForTimeout(300);
// Switch and return
await switchTab(page, sB);
await switchTab(page, sA);
await focus(page);
// Check flushed offset is restored
st = await getState(page);
console.log(` Back on A: flushed=${st?.flushed}`);
// Type a space immediately
await page.keyboard.type(' ', { delay: 30 });
await page.waitForTimeout(300);
st = await getState(page);
console.log(` After space: text="${st?.text}", flushed=${st?.flushed}`);
console.log(` Children: ${JSON.stringify(st?.children?.map(c => c.text))}`);
await sc(page, '03-after-space');
// The overlay should show the flushed text + space
if (st?.text === ' ' && st?.flushed > 0) pass('2a: Space typed with flushed offset');
else fail('2a: Space', `text="${st?.text}", flushed=${st?.flushed}`);
// The first child should contain the flushed text + space (not just space)
const child = st?.children?.[0];
if (child && child.text && child.text.length > 1) {
pass('2b: Overlay shows flushed text + space (not just space)');
console.log(` Overlay text: "${child.text}"`);
} else {
fail('2b: Overlay content', `child text="${child?.text}"`);
}
// Clean up
await page.keyboard.press('Control+c');
await page.waitForTimeout(500);
// ══════════════════════════════════════════════════════════════
// TEST 3: Type → switch → back → backspace some + type new text
// ══════════════════════════════════════════════════════════════
console.log('\n--- 3. Type → switch → back → backspace + retype ---');
await focus(page);
await waitForPrompt(page);
await page.keyboard.type('old text here', { delay: 30 });
await page.waitForTimeout(300);
// Switch and return
await switchTab(page, sB);
await switchTab(page, sA);
await focus(page);
await page.waitForTimeout(500); // Small wait for buffer
// Backspace 4 chars ("here")
for (let i = 0; i < 4; i++) {
await page.keyboard.press('Backspace');
await page.waitForTimeout(50);
}
await page.waitForTimeout(300);
st = await getState(page);
console.log(` After 4 backspaces: flushed=${st?.flushed}, text="${st?.text}"`);
if (st?.flushed === 9) pass('3a: Backspace 4 leaves flushed=9');
else fail('3a: Backspace count', `expected flushed=9, got ${st?.flushed}`);
// Now type new text
await page.keyboard.type('new stuff', { delay: 30 });
await page.waitForTimeout(300);
st = await getState(page);
console.log(` After retype: text="${st?.text}", flushed=${st?.flushed}`);
console.log(` Overlay: ${JSON.stringify(st?.children?.map(c => c.text))}`);
await sc(page, '04-backspace-retype');
if (st?.text === 'new stuff') pass('3b: New text after backspace correct');
else fail('3b: New text', `"${st?.text}"`);
// Overlay should show flushed chars + new text
const overlayText3 = st?.children?.[0]?.text || '';
console.log(` Full overlay text: "${overlayText3}"`);
if (overlayText3.endsWith('new stuff') && overlayText3.length > 'new stuff'.length) {
pass('3c: Overlay combines flushed + new text');
} else {
fail('3c: Combined overlay', `"${overlayText3}"`);
}
await page.keyboard.press('Control+c');
await page.waitForTimeout(500);
// ══════════════════════════════════════════════════════════════
// TEST 4: Overlay position consistency
// ══════════════════════════════════════════════════════════════
console.log('\n--- 4. Overlay position consistency ---');
await focus(page);
await waitForPrompt(page);
// Type and record position
await page.keyboard.type('position test', { delay: 30 });
await page.waitForTimeout(300);
st = await getState(page);
const pos1 = {
overlayLeft: st?.overlayLeft,
overlayTop: st?.overlayTop,
childLeft: st?.children?.[0]?.left,
promptCol: st?.promptLine?.col,
};
console.log(` Initial position: overlay=(${pos1.overlayLeft}, ${pos1.overlayTop}), child left=${pos1.childLeft}, prompt col=${pos1.promptCol}`);
await sc(page, '05-position-1');
// Type more text — position should stay stable (left doesn't change)
await page.keyboard.type('!', { delay: 30 });
await page.waitForTimeout(300);
st = await getState(page);
const pos2 = {
overlayLeft: st?.overlayLeft,
overlayTop: st?.overlayTop,
childLeft: st?.children?.[0]?.left,
promptCol: st?.promptLine?.col,
};
console.log(` After more text: overlay=(${pos2.overlayLeft}, ${pos2.overlayTop}), child left=${pos2.childLeft}, prompt col=${pos2.promptCol}`);
if (pos1.overlayLeft === pos2.overlayLeft && pos1.overlayTop === pos2.overlayTop) {
pass('4a: Overlay container position stable');
} else {
fail('4a: Overlay shifted', `${pos1.overlayLeft},${pos1.overlayTop} → ${pos2.overlayLeft},${pos2.overlayTop}`);
}
if (pos1.childLeft === pos2.childLeft) {
pass('4b: Child left position stable');
} else {
fail('4b: Child left shifted', `${pos1.childLeft} → ${pos2.childLeft}`);
}
await page.keyboard.press('Control+c');
await page.waitForTimeout(500);
// ══════════════════════════════════════════════════════════════
// TEST 5: Switch → return → type → position matches prompt
// ══════════════════════════════════════════════════════════════
console.log('\n--- 5. Position after tab switch ---');
await focus(page);
await waitForPrompt(page);
// Record prompt position
st = await getState(page);
const promptCol5 = st?.promptLine?.col;
console.log(` Prompt col before: ${promptCol5}`);
await page.keyboard.type('before switch', { delay: 30 });
await page.waitForTimeout(300);
st = await getState(page);
const beforeLeft = st?.children?.[0]?.left;
console.log(` Overlay left before switch: ${beforeLeft}`);
await sc(page, '06-before-switch');
// Switch and return
await switchTab(page, sB);
await switchTab(page, sA);
await focus(page);
await page.waitForTimeout(500);
// Type something
await page.keyboard.type(' more', { delay: 30 });
await page.waitForTimeout(300);
st = await getState(page);
const afterLeft = st?.children?.[0]?.left;
const promptCol5After = st?.promptLine?.col;
console.log(` Prompt col after: ${promptCol5After}`);
console.log(` Overlay left after switch+type: ${afterLeft}`);
await sc(page, '07-after-switch-type');
if (beforeLeft === afterLeft) {
pass('5a: Overlay position stable after tab switch');
} else {
fail('5a: Position shifted after switch', `${beforeLeft} → ${afterLeft}`);
}
await page.keyboard.press('Control+c');
await page.waitForTimeout(500);
// ══════════════════════════════════════════════════════════════
// TEST 6: Rapid backspace-and-retype after return
// ══════════════════════════════════════════════════════════════
console.log('\n--- 6. Rapid backspace-and-retype ---');
await focus(page);
await waitForPrompt(page);
await page.keyboard.type('rapid test', { delay: 20 });
await page.waitForTimeout(200);
// Switch and return
await switchTab(page, sB);
await switchTab(page, sA);
await focus(page);
// Rapidly: backspace 4, type "done"
for (let i = 0; i < 4; i++) await page.keyboard.press('Backspace');
await page.keyboard.type('done', { delay: 10 });
await page.waitForTimeout(300);
st = await getState(page);
console.log(` After rapid edit: text="${st?.text}", flushed=${st?.flushed}`);
await sc(page, '08-rapid-edit');
if (st?.text === 'done') pass('6a: Rapid backspace+retype text correct');
else fail('6a: Rapid edit text', `"${st?.text}"`);
if (st?.flushed === 6) pass('6b: Flushed offset correct after rapid backspace');
else fail('6b: Flushed offset', `expected 6, got ${st?.flushed}`);
await page.keyboard.press('Control+c');
await page.waitForTimeout(500);
// ══════════════════════════════════════════════════════════════
// TEST 7: Overlay background covers canvas text
// ══════════════════════════════════════════════════════════════
console.log('\n--- 7. Overlay covers canvas text ---');
await focus(page);
await waitForPrompt(page);
await page.keyboard.type('canvas cover', { delay: 30 });
await page.waitForTimeout(200);
// Flush by switching tabs
await switchTab(page, sB);
await switchTab(page, sA);
await focus(page);
await page.waitForTimeout(500); // Wait for buffer
// Type a space — overlay should render flushed text + space
await page.keyboard.type('X', { delay: 30 });
await page.waitForTimeout(300);
st = await getState(page);
const overlayChild = st?.children?.[0];
console.log(` Overlay: visible=${st?.visible}, text="${overlayChild?.text}", width=${overlayChild?.width}, bg=${overlayChild?.bgColor}`);
await sc(page, '09-canvas-cover');
if (st?.visible && overlayChild?.text?.includes('canvas cover')) {
pass('7a: Overlay contains flushed text');
} else {
fail('7a: Flushed text in overlay', `"${overlayChild?.text}"`);
}
if (overlayChild?.bgColor) {
pass('7b: Overlay has background color');
} else {
fail('7b: No background color', `bg="${overlayChild?.bgColor}"`);
}
await page.keyboard.press('Control+c');
await page.waitForTimeout(500);
// ══════════════════════════════════════════════════════════════
// TEST 8: Backspace ALL flushed text back to zero
// ══════════════════════════════════════════════════════════════
console.log('\n--- 8. Full backspace through flushed text ---');
await focus(page);
await waitForPrompt(page);
await page.keyboard.type('abcde', { delay: 20 });
await page.waitForTimeout(200);
// Flush
await switchTab(page, sB);
await switchTab(page, sA);
await focus(page);
st = await getState(page);
console.log(` Flushed: ${st?.flushed}`);
// Backspace ALL 5 chars + 2 extra (should be no-ops)
for (let i = 0; i < 7; i++) {
await page.keyboard.press('Backspace');
await page.waitForTimeout(40);
}
await page.waitForTimeout(300);
st = await getState(page);
console.log(` After full backspace: flushed=${st?.flushed}, text="${st?.text}", visible=${st?.visible}`);
await sc(page, '10-full-backspace');
if (st?.flushed === 0) pass('8a: Flushed offset back to 0');
else fail('8a: Flushed not zero', `${st?.flushed}`);
if (!st?.text && !st?.visible) pass('8b: Overlay hidden after full backspace');
else fail('8b: Overlay state', `text="${st?.text}", visible=${st?.visible}`);
await page.keyboard.press('Control+c');
await page.waitForTimeout(500);
// ══════════════════════════════════════════════════════════════
// TEST 9: Fresh type after full backspace
// ══════════════════════════════════════════════════════════════
console.log('\n--- 9. Type fresh after full backspace ---');
await focus(page);
await waitForPrompt(page);
await page.keyboard.type('delete me', { delay: 20 });
await page.waitForTimeout(200);
// Flush
await switchTab(page, sB);
await switchTab(page, sA);
await focus(page);
// Delete all
for (let i = 0; i < 12; i++) {
await page.keyboard.press('Backspace');
await page.waitForTimeout(30);
}
await page.waitForTimeout(200);
// Now type fresh text
await page.keyboard.type('brand new', { delay: 30 });
await page.waitForTimeout(300);
st = await getState(page);
console.log(` Fresh type: text="${st?.text}", flushed=${st?.flushed}, visible=${st?.visible}`);
await sc(page, '11-fresh-type');
if (st?.text === 'brand new' && st?.flushed === 0) {
pass('9a: Clean fresh text after full delete');
} else {
fail('9a: Fresh text', `text="${st?.text}", flushed=${st?.flushed}`);
}
if (st?.visible) pass('9b: Overlay visible with fresh text');
else fail('9b: Overlay not visible');
await page.keyboard.press('Control+c');
await page.waitForTimeout(500);
// ══════════════════════════════════════════════════════════════
// JS ERRORS
// ══════════════════════════════════════════════════════════════
console.log('\n--- JS Errors ---');
if (jsErrors.length === 0) {
pass('No JS errors');
} else {
for (const err of jsErrors) {
fail('JS Error', err.slice(0, 120));
}
}
await sc(page, '12-final');
// ══════════════════════════════════════════════════════════════
// SUMMARY
// ══════════════════════════════════════════════════════════════
console.log('\n=== SUMMARY ===');
const passes = R.filter(r => r.s === 'PASS').length;
const fails = R.filter(r => r.s === 'FAIL').length;
console.log(` ${passes} passed, ${fails} failed`);
if (fails > 0) {
console.log('\n FAILURES:');
R.filter(r => r.s === 'FAIL').forEach(r => console.log(` ❌ ${r.n}: ${r.d}`));
}
} finally {
await browser.close();
console.log('\nCleaning up...');
await deleteSession(sA);
await deleteSession(sB);
}
}
main().catch(err => { console.error('Test failed:', err); process.exit(1); });
-395
View File
@@ -1,395 +0,0 @@
/**
* Local Echo Overlay — Daily use scenarios test.
*
* Simulates real mobile usage:
* 1. Type text → switch tabs → switch back → verify state
* 2. Type → change mind → delete all → type something else
* 3. Type → reload page → verify recovery
* 4. Rapidly type-delete-type (simulate editing)
* 5. Type on Tab A → switch B → type on B → switch A → verify both
* 6. Type → Enter → wait for response → type again
* 7. Partial delete then add more text
*
* Usage: node test/local-echo-daily-test.mjs
*/
import { chromium } from 'playwright';
const PORT = 3000;
const BASE = `http://localhost:${PORT}`;
const SCREENSHOT_DIR = '/tmp/local-echo-daily';
async function createSession(name) {
const resp = await fetch(`${BASE}/api/sessions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mode: 'claude', name })
});
if (!resp.ok) throw new Error(`Failed: ${resp.status}`);
const data = await resp.json();
return data.session?.id || data.id;
}
async function deleteSession(id) {
await fetch(`${BASE}/api/sessions/${id}`, { method: 'DELETE' }).catch(() => {});
}
async function waitForReady(id, ms = 15000) {
const start = Date.now();
while (Date.now() - start < ms) {
const r = await fetch(`${BASE}/api/sessions/${id}`);
if (r.ok) { const d = await r.json(); if (d.status === 'idle' || d.status === 'busy') return true; }
await new Promise(r => setTimeout(r, 300));
}
return false;
}
async function getState(page) {
return page.evaluate(() => {
const app = window.app;
if (!app) return null;
const ov = app._localEchoOverlay;
return {
visible: ov?.overlay?.style.display !== 'none',
text: ov?.pendingText || '',
flushed: ov?._flushedOffset || 0,
flushedMap: app._flushedOffsets ? Object.fromEntries(app._flushedOffsets) : {},
enabled: app._localEchoEnabled,
activeId: app.activeSessionId,
children: (() => {
if (!ov?.overlay) return [];
return Array.from(ov.overlay.children).map(c => ({
text: Array.from(c.querySelectorAll('span')).map(s => s.textContent).join(''),
left: c.style.left, top: c.style.top,
}));
})(),
};
});
}
async function focus(page) {
await page.evaluate(() => document.querySelector('.xterm-helper-textarea')?.focus());
await page.waitForTimeout(200);
}
async function switchTab(page, id) {
await page.evaluate((id) => document.querySelector(`.session-tab[data-id="${id}"]`)?.click(), id);
await page.waitForTimeout(1500);
}
async function sc(page, name) {
const p = `${SCREENSHOT_DIR}/${name}.png`;
await page.screenshot({ path: p });
console.log(` 📸 ${p}`);
}
const R = [];
const pass = (n) => { R.push({n, s:'PASS'}); console.log(` ✅ ${n}`); };
const fail = (n, d) => { R.push({n, s:'FAIL', d}); console.log(` ❌ ${n} — ${d}`); };
const info = (n, d) => { R.push({n, s:'INFO', d}); console.log(` ℹ️ ${n} — ${d}`); };
async function main() {
const { mkdirSync } = await import('fs');
try { mkdirSync(SCREENSHOT_DIR, { recursive: true }); } catch {}
console.log('=== Local Echo — Daily Use Tests ===\n');
const sA = await createSession('daily-A');
const sB = await createSession('daily-B');
console.log(` A: ${sA}\n B: ${sB}`);
if (!await waitForReady(sA) || !await waitForReady(sB)) {
await deleteSession(sA); await deleteSession(sB);
throw new Error('Sessions not ready');
}
console.log(' Ready\n');
const browser = await chromium.launch({ headless: true });
const ctx = await browser.newContext({
viewport: { width: 390, height: 844 },
userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0)',
isMobile: true, hasTouch: true,
});
const page = await ctx.newPage();
try {
await page.goto(BASE, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('.xterm-screen', { timeout: 10000 });
await page.waitForTimeout(2000);
// Enable local echo
await page.evaluate(() => {
const s = JSON.parse(localStorage.getItem('codeman-app-settings') || '{}');
s.localEchoEnabled = true;
localStorage.setItem('codeman-app-settings', JSON.stringify(s));
window.app?._updateLocalEchoState?.();
});
await switchTab(page, sA);
await focus(page);
// ══════════════════════════════════════════
// 1. Type → switch tab → switch back
// ══════════════════════════════════════════
console.log('--- 1. Type → Tab switch → Return ---');
await page.keyboard.type('fix the login bug', { delay: 40 });
await page.waitForTimeout(300);
let st = await getState(page);
await sc(page, '01-typed');
console.log(` Typed: "${st?.text}"`);
if (st?.text === 'fix the login bug') pass('Text buffered');
else fail('Buffer', `"${st?.text}"`);
// Switch to B
await switchTab(page, sB);
st = await getState(page);
console.log(` After switch to B: text="${st?.text}", flushed A=${st?.flushedMap?.[sA]}`);
await sc(page, '02-on-tab-b');
if (!st?.text) pass('Overlay cleared on switch');
else fail('Clear on switch', `"${st?.text}"`);
// Switch back to A
await switchTab(page, sA);
await focus(page);
st = await getState(page);
await sc(page, '03-back-on-a');
console.log(` Back on A: text="${st?.text}", flushed=${st?.flushed}`);
if (!st?.text && st?.flushed > 0) pass('Flushed text preserved on return');
else info('Return state', `text="${st?.text}", flushed=${st?.flushed}`);
// Type more
await page.keyboard.type(' please', { delay: 40 });
await page.waitForTimeout(300);
st = await getState(page);
await sc(page, '04-type-more-on-a');
console.log(` After adding: text="${st?.text}", flushed=${st?.flushed}`);
if (st?.text === ' please' && st?.flushed > 0) pass('Can type after return with flushed offset');
else fail('Type after return', `text="${st?.text}", flushed=${st?.flushed}`);
// Submit
await page.keyboard.press('Enter');
await page.waitForTimeout(2000);
// ══════════════════════════════════════════
// 2. Type → delete all → retype
// ══════════════════════════════════════════
console.log('\n--- 2. Type → delete all → retype ---');
await focus(page);
await page.keyboard.type('wrong command oops', { delay: 30 });
await page.waitForTimeout(200);
st = await getState(page);
console.log(` Typed wrong: "${st?.text}"`);
// Delete all
for (let i = 0; i < 'wrong command oops'.length + 2; i++) {
await page.keyboard.press('Backspace');
await page.waitForTimeout(20);
}
await page.waitForTimeout(200);
st = await getState(page);
console.log(` After delete: text="${st?.text}", visible=${st?.visible}`);
if (!st?.text && !st?.visible) pass('Fully deleted');
else fail('Full delete', `"${st?.text}"`);
// Retype correct command
await page.keyboard.type('say hello', { delay: 40 });
await page.waitForTimeout(300);
st = await getState(page);
await sc(page, '05-retyped');
console.log(` Retyped: "${st?.text}"`);
if (st?.text === 'say hello' && st?.visible) pass('Retype after delete works');
else fail('Retype', `"${st?.text}", visible=${st?.visible}`);
await page.keyboard.press('Enter');
await page.waitForTimeout(2000);
// ══════════════════════════════════════════
// 3. Type → reload page → check recovery
// ══════════════════════════════════════════
console.log('\n--- 3. Type → reload page → recovery ---');
await focus(page);
await page.keyboard.type('unsent text before reload', { delay: 30 });
await page.waitForTimeout(300);
st = await getState(page);
console.log(` Before reload: "${st?.text}"`);
await sc(page, '06-before-reload');
// Reload the page
await page.reload({ waitUntil: 'domcontentloaded' });
await page.waitForSelector('.xterm-screen', { timeout: 10000 });
await page.waitForTimeout(3000); // Wait for SSE reconnect + buffer load
st = await getState(page);
await sc(page, '07-after-reload');
console.log(` After reload: text="${st?.text}", enabled=${st?.enabled}`);
// localStorage persistence was removed — stale text from previous page
// loads caused ghost-merging with new input, breaking editing.
// After reload, overlay should be clean (no stale text).
if (st?.text === '') {
pass('Overlay clean after reload (no stale text)');
} else {
fail('Stale text after reload', `text="${st?.text}"`);
}
// ══════════════════════════════════════════
// 4. Rapid type-delete-type (simulate editing)
// ══════════════════════════════════════════
console.log('\n--- 4. Rapid type-delete-type editing ---');
await focus(page);
// Type "hello", delete "lo", type "p me"
await page.keyboard.type('hello', { delay: 20 });
await page.waitForTimeout(100);
await page.keyboard.press('Backspace');
await page.keyboard.press('Backspace');
await page.waitForTimeout(100);
await page.keyboard.type('p me', { delay: 20 });
await page.waitForTimeout(300);
st = await getState(page);
await sc(page, '08-rapid-edit');
console.log(` After rapid edit: "${st?.text}"`);
if (st?.text === 'help me') pass('Rapid edit produces correct text');
else fail('Rapid edit', `expected "help me", got "${st?.text}"`);
await page.keyboard.press('Control+c');
await page.waitForTimeout(500);
// ══════════════════════════════════════════
// 5. Interleaved typing on two tabs
// ══════════════════════════════════════════
console.log('\n--- 5. Interleaved typing on two tabs ---');
await focus(page);
await page.keyboard.type('tab A input', { delay: 30 });
await page.waitForTimeout(200);
await sc(page, '09-tab-a-input');
st = await getState(page);
console.log(` Tab A: "${st?.text}"`);
if (st?.text === 'tab A input') pass('Tab A text correct');
else fail('Tab A text', `"${st?.text}"`);
// Switch to B and type
await switchTab(page, sB);
await focus(page);
await page.keyboard.type('tab B input', { delay: 30 });
await page.waitForTimeout(200);
await sc(page, '10-tab-b-input');
st = await getState(page);
console.log(` Tab B: "${st?.text}"`);
if (st?.text === 'tab B input') pass('Tab B text correct');
else fail('Tab B text', `"${st?.text}"`);
// Switch back to A — A's text was flushed
await switchTab(page, sA);
await focus(page);
st = await getState(page);
await sc(page, '11-back-to-a');
console.log(` Back to A: text="${st?.text}", flushed=${st?.flushed}, flushedMap A=${st?.flushedMap?.[sA]}, B=${st?.flushedMap?.[sB]}`);
// A's input was flushed (11 chars), B's was flushed (11 chars)
if (st?.flushed > 0) pass('Tab A flushed offset restored');
else info('Tab A flushed', `offset=${st?.flushed}`);
// Switch to B — B's input was flushed
await switchTab(page, sB);
await focus(page);
st = await getState(page);
await sc(page, '12-back-to-b');
console.log(` Back to B: text="${st?.text}", flushed=${st?.flushed}`);
if (st?.flushed > 0) pass('Tab B flushed offset restored');
else info('Tab B flushed', `offset=${st?.flushed}`);
// Submit both with Enter
await page.keyboard.press('Enter');
await page.waitForTimeout(1000);
await switchTab(page, sA);
await focus(page);
await page.keyboard.press('Enter');
await page.waitForTimeout(1000);
// ══════════════════════════════════════════
// 6. Type → Enter → response → type again
// ══════════════════════════════════════════
console.log('\n--- 6. Type → Enter → wait → type again ---');
await focus(page);
await page.keyboard.type('just say ok', { delay: 30 });
await page.keyboard.press('Enter');
await page.waitForTimeout(4000); // Wait for response
await focus(page);
await page.keyboard.type('now say goodbye', { delay: 30 });
await page.waitForTimeout(300);
st = await getState(page);
await sc(page, '13-second-prompt');
console.log(` Second prompt: "${st?.text}", flushed=${st?.flushed}`);
if (st?.text === 'now say goodbye' && st?.flushed === 0) {
pass('Clean typing on new prompt after response');
} else {
fail('Second prompt', `text="${st?.text}", flushed=${st?.flushed}`);
}
await page.keyboard.press('Control+c');
await page.waitForTimeout(500);
// ══════════════════════════════════════════
// 7. Partial delete then add text
// ══════════════════════════════════════════
console.log('\n--- 7. Partial delete + add ---');
await focus(page);
await page.keyboard.type('fixing the bug', { delay: 30 });
await page.waitForTimeout(200);
// Delete "bug" (3 chars), keeping the space
for (let i = 0; i < 3; i++) await page.keyboard.press('Backspace');
await page.waitForTimeout(100);
// Add "typo"
await page.keyboard.type('typo', { delay: 30 });
await page.waitForTimeout(300);
st = await getState(page);
await sc(page, '14-partial-edit');
console.log(` After partial edit: "${st?.text}"`);
if (st?.text === 'fixing the typo') pass('Partial delete + add correct');
else fail('Partial edit', `expected "fixing the typo", got "${st?.text}"`);
// Final
await page.keyboard.press('Control+c');
await page.waitForTimeout(300);
await sc(page, '15-final');
// ══════════════════════════════════════════
// SUMMARY
// ══════════════════════════════════════════
console.log('\n=== SUMMARY ===');
const p = R.filter(r => r.s === 'PASS').length;
const f = R.filter(r => r.s === 'FAIL').length;
const i = R.filter(r => r.s === 'INFO').length;
console.log(` ${p} passed, ${f} failed, ${i} info`);
if (f > 0) {
console.log('\n FAILURES:');
R.filter(r => r.s === 'FAIL').forEach(r => console.log(` ❌ ${r.n}: ${r.d}`));
}
} finally {
await browser.close();
console.log('\nCleaning up...');
await deleteSession(sA);
await deleteSession(sB);
}
}
main().catch(err => { console.error('Test failed:', err); process.exit(1); });
-533
View File
@@ -1,533 +0,0 @@
/**
* Local Echo Overlay — Comprehensive edge case tests.
*
* Tests: reload, tab switching, backspace, Ctrl+C, paste, rapid switches,
* empty Enter, long text wrap, flushed offset tracking, and font rendering.
*
* Usage: node test/local-echo-edge-test.mjs
*/
import { chromium } from 'playwright';
const PORT = 3000;
const BASE = `http://localhost:${PORT}`;
const SCREENSHOT_DIR = '/tmp/local-echo-edge';
const results = [];
function pass(name) { results.push({ name, ok: true }); console.log(` ✅ ${name}`); }
function fail(name, detail) { results.push({ name, ok: false, detail }); console.log(` ❌ ${name} — ${detail}`); }
async function createSession(name) {
const resp = await fetch(`${BASE}/api/sessions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mode: 'claude', name })
});
if (!resp.ok) throw new Error(`Failed to create session '${name}': ${resp.status}`);
const data = await resp.json();
return data.session?.id || data.id;
}
async function deleteSession(id) {
await fetch(`${BASE}/api/sessions/${id}`, { method: 'DELETE' }).catch(() => {});
}
async function waitForReady(id, ms = 15000) {
const start = Date.now();
while (Date.now() - start < ms) {
const r = await fetch(`${BASE}/api/sessions/${id}`);
if (r.ok) { const d = await r.json(); if (d.status === 'idle' || d.status === 'busy') return true; }
await new Promise(r => setTimeout(r, 300));
}
return false;
}
async function getState(page) {
return page.evaluate(() => {
const app = window.app;
if (!app) return null;
const overlay = app._localEchoOverlay;
return {
localEchoEnabled: app._localEchoEnabled,
overlayVisible: overlay?.overlay?.style.display !== 'none',
overlayText: overlay?.pendingText || '',
flushedOffset: overlay?._flushedOffset || 0,
flushedOffsets: app._flushedOffsets ? Object.fromEntries(app._flushedOffsets) : {},
activeSessionId: app.activeSessionId,
overlayChildCount: overlay?.overlay?.children?.length || 0,
overlaySpanCSS: (() => {
const span = overlay?.overlay?.children?.[0]?.children?.[0];
if (!span) return null;
const cs = getComputedStyle(span);
return {
fontSmoothing: cs.webkitFontSmoothing || 'N/A',
textRendering: cs.textRendering,
fontFeatures: cs.fontFeatureSettings,
};
})(),
};
});
}
async function focusTerminal(page) {
await page.evaluate(() => document.querySelector('.xterm-helper-textarea')?.focus());
await page.waitForTimeout(200);
}
async function screenshot(page, name) {
const path = `${SCREENSHOT_DIR}/${name}.png`;
await page.screenshot({ path, fullPage: false });
console.log(` 📸 ${name}.png`);
}
async function selectSession(page, sessionId) {
await page.evaluate((id) => {
const t = document.querySelector(`.session-tab[data-id="${id}"]`);
if (t) t.click();
}, sessionId);
await page.waitForTimeout(1500);
}
async function main() {
const { mkdirSync } = await import('fs');
try { mkdirSync(SCREENSHOT_DIR, { recursive: true }); } catch {}
console.log('=== Local Echo — Comprehensive Edge Case Tests ===\n');
const sA = await createSession('edge-A');
const sB = await createSession('edge-B');
const sC = await createSession('edge-C');
console.log(` Sessions: A=${sA.slice(0,8)}, B=${sB.slice(0,8)}, C=${sC.slice(0,8)}`);
for (const s of [sA, sB, sC]) {
if (!await waitForReady(s)) { console.error(`Session ${s} not ready`); process.exit(1); }
}
console.log(' All ready.\n');
const browser = await chromium.launch({ headless: true, args: ['--window-size=390,844'] });
const ctx = await browser.newContext({
viewport: { width: 390, height: 844 },
userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0)',
isMobile: true, hasTouch: true,
});
const page = await ctx.newPage();
const jsErrors = [];
page.on('pageerror', err => jsErrors.push(err.message));
try {
await page.goto(BASE, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('.xterm-screen', { timeout: 10000 });
await page.waitForTimeout(2000);
// Enable local echo
await page.evaluate(() => {
const s = JSON.parse(localStorage.getItem('codeman-app-settings') || '{}');
s.localEchoEnabled = true;
localStorage.setItem('codeman-app-settings', JSON.stringify(s));
window.app?._updateLocalEchoState?.();
});
// ═══════════════════════════════════════════
// 1. Fresh typing on session A
// ═══════════════════════════════════════════
console.log('── 1. Fresh typing ──');
await selectSession(page, sA);
// Wait for ❯ prompt to appear in terminal buffer (Claude Code startup)
for (let i = 0; i < 20; i++) {
const hasPrompt = await page.evaluate(() => {
const buf = window.app?.terminal?.buffer?.active;
if (!buf) return false;
for (let r = 0; r < window.app.terminal.rows; r++) {
const line = buf.getLine(buf.viewportY + r);
if (line && line.translateToString(true).includes('\u276f')) return true;
}
return false;
});
if (hasPrompt) break;
await page.waitForTimeout(500);
}
await focusTerminal(page);
await page.keyboard.type('hello world', { delay: 20 });
await page.waitForTimeout(200);
let st = await getState(page);
if (st.overlayText === 'hello world' && st.overlayVisible) pass('Fresh typing shows in overlay');
else fail('Fresh typing', `text="${st.overlayText}", visible=${st.overlayVisible}`);
if (st.overlaySpanCSS?.textRendering === 'geometricprecision') pass('Font CSS applied');
else fail('Font CSS', JSON.stringify(st.overlaySpanCSS));
await screenshot(page, '01-fresh-type');
// ═══════════════════════════════════════════
// 2. Backspace removes from overlay (not PTY)
// ═══════════════════════════════════════════
console.log('── 2. Backspace ──');
await page.keyboard.press('Backspace');
await page.keyboard.press('Backspace');
await page.waitForTimeout(200);
st = await getState(page);
if (st.overlayText === 'hello wor') pass('Backspace removes chars from overlay');
else fail('Backspace', `text="${st.overlayText}"`);
// Backspace to empty
for (let i = 0; i < 20; i++) await page.keyboard.press('Backspace');
await page.waitForTimeout(200);
st = await getState(page);
if (st.overlayText === '' && !st.overlayVisible) pass('Backspace to empty hides overlay');
else fail('Backspace to empty', `text="${st.overlayText}", visible=${st.overlayVisible}`);
// Extra backspace on empty — should not crash
await page.keyboard.press('Backspace');
await page.keyboard.press('Backspace');
await page.waitForTimeout(100);
st = await getState(page);
if (st.overlayText === '') pass('Extra backspace on empty is no-op');
else fail('Extra backspace', `text="${st.overlayText}"`);
// ═══════════════════════════════════════════
// 3. Enter on empty prompt — no-op
// ═══════════════════════════════════════════
console.log('── 3. Enter on empty ──');
await page.keyboard.press('Enter');
await page.waitForTimeout(500);
st = await getState(page);
if (st.overlayText === '' && st.flushedOffset === 0) pass('Enter on empty is harmless');
else fail('Enter on empty', `text="${st.overlayText}", flushed=${st.flushedOffset}`);
// ═══════════════════════════════════════════
// 4. Tab switch A→B: text flushed to PTY
// ═══════════════════════════════════════════
console.log('── 4. Tab switch flush ──');
await focusTerminal(page);
await page.keyboard.type('flush-me', { delay: 20 });
await page.waitForTimeout(200);
await selectSession(page, sB);
await page.waitForTimeout(500);
st = await getState(page);
if (st.overlayText === '') pass('Overlay cleared after tab switch');
else fail('Overlay after switch', `text="${st.overlayText}"`);
const flushedA = st.flushedOffsets[sA] || 0;
if (flushedA === 8) pass('Flushed offset tracked for session A (8 chars)');
else fail('Flushed offset', `expected 8, got ${flushedA}`);
// ═══════════════════════════════════════════
// 5. Type on B, switch to C, switch back to B
// ═══════════════════════════════════════════
console.log('── 5. Triple switch B→C→B ──');
await focusTerminal(page);
await page.keyboard.type('on-B', { delay: 20 });
await page.waitForTimeout(200);
await selectSession(page, sC);
await page.waitForTimeout(500);
st = await getState(page);
const flushedB = st.flushedOffsets[sB] || 0;
if (flushedB === 4) pass('B flushed on switch to C');
else fail('B flush', `expected 4, got ${flushedB}`);
await focusTerminal(page);
await page.keyboard.type('on-C', { delay: 20 });
await page.waitForTimeout(200);
await selectSession(page, sB);
await page.waitForTimeout(500);
st = await getState(page);
if (st.overlayText === '') pass('Overlay empty on return to B');
else fail('Return to B overlay', `text="${st.overlayText}"`);
if (st.flushedOffset === 4) pass('Flushed offset restored for B');
else fail('B flushed offset restore', `expected 4, got ${st.flushedOffset}`);
await screenshot(page, '05-return-to-B');
// ═══════════════════════════════════════════
// 6. Type after return (append to flushed)
// ═══════════════════════════════════════════
console.log('── 6. Type after return to flushed session ──');
await focusTerminal(page);
await page.keyboard.type(' more', { delay: 20 });
await page.waitForTimeout(200);
st = await getState(page);
if (st.overlayText === ' more' && st.flushedOffset === 4) pass('Append after flushed');
else fail('Append after flush', `text="${st.overlayText}", flushed=${st.flushedOffset}`);
await screenshot(page, '06-append-after-flush');
// ═══════════════════════════════════════════
// 7. Enter clears flushed offset
// ═══════════════════════════════════════════
console.log('── 7. Enter clears flushed offset ──');
await page.keyboard.press('Enter');
await page.waitForTimeout(1000);
st = await getState(page);
if (st.overlayText === '' && st.flushedOffset === 0) pass('Enter clears overlay + flushed');
else fail('Enter clear', `text="${st.overlayText}"`);
const bInMap = st.flushedOffsets[sB];
if (!bInMap) pass('Flushed offset map cleared for B');
else fail('Map clear', `B still in map: ${bInMap}`);
// ═══════════════════════════════════════════
// 8. Ctrl+C during typing
// ═══════════════════════════════════════════
console.log('── 8. Ctrl+C during typing ──');
await page.waitForTimeout(3000);
await focusTerminal(page);
await page.keyboard.type('will-be-cancelled', { delay: 20 });
await page.waitForTimeout(200);
await page.keyboard.down('Control');
await page.keyboard.press('c');
await page.keyboard.up('Control');
await page.waitForTimeout(500);
st = await getState(page);
if (st.overlayText === '') pass('Ctrl+C clears overlay');
else fail('Ctrl+C clear', `text="${st.overlayText}"`);
await screenshot(page, '08-ctrl-c');
// ═══════════════════════════════════════════
// 9. Paste (multi-char data)
// ═══════════════════════════════════════════
console.log('── 9. Paste ──');
await page.waitForTimeout(2000);
await focusTerminal(page);
await page.evaluate(() => {
window.app?._localEchoOverlay?.appendText('pasted-text');
});
await page.waitForTimeout(200);
st = await getState(page);
if (st.overlayText === 'pasted-text') pass('Paste appends to overlay');
else fail('Paste', `text="${st.overlayText}"`);
await page.keyboard.down('Control');
await page.keyboard.press('c');
await page.keyboard.up('Control');
await page.waitForTimeout(500);
// ═══════════════════════════════════════════
// 10. Reload — stale text NOT restored
// ═══════════════════════════════════════════
console.log('── 10. Browser reload ──');
await page.waitForTimeout(2000);
await focusTerminal(page);
await page.keyboard.type('will-be-lost', { delay: 20 });
await page.waitForTimeout(200);
const lsBefore = await page.evaluate(() => localStorage.getItem('codeman_local_echo_pending'));
if (!lsBefore) pass('localStorage not written (persist is no-op)');
else fail('localStorage written', `value="${lsBefore}"`);
await page.reload({ waitUntil: 'domcontentloaded' });
await page.waitForSelector('.xterm-screen', { timeout: 10000 });
await page.waitForTimeout(3000);
await page.evaluate(() => window.app?._updateLocalEchoState?.());
st = await getState(page);
if (st.overlayText === '') pass('Overlay empty after reload (no stale text)');
else fail('Stale text after reload', `text="${st.overlayText}"`);
if (st.flushedOffset === 0) pass('Flushed offset reset after reload');
else fail('Flushed offset after reload', `flushed=${st.flushedOffset}`);
if (Object.keys(st.flushedOffsets).length === 0) pass('Flushed offsets map empty after reload');
else fail('Flushed map after reload', JSON.stringify(st.flushedOffsets));
// ═══════════════════════════════════════════
// 11. Type after reload — works normally
// ═══════════════════════════════════════════
console.log('── 11. Type after reload ──');
await focusTerminal(page);
await page.keyboard.type('after reload', { delay: 20 });
await page.waitForTimeout(300);
st = await getState(page);
if (st.overlayText === 'after reload' && st.overlayVisible) pass('Typing works after reload');
else fail('Type after reload', `text="${st.overlayText}", visible=${st.overlayVisible}`);
await screenshot(page, '11-after-reload');
await page.keyboard.press('Backspace');
await page.keyboard.press('Backspace');
await page.waitForTimeout(200);
st = await getState(page);
if (st.overlayText === 'after relo') pass('Backspace works after reload');
else fail('Backspace after reload', `text="${st.overlayText}"`);
await page.keyboard.press('Enter');
await page.waitForTimeout(1000);
st = await getState(page);
if (st.overlayText === '' && st.flushedOffset === 0) pass('Enter works after reload');
else fail('Enter after reload', `text="${st.overlayText}"`);
await screenshot(page, '11-enter-after-reload');
// ═══════════════════════════════════════════
// 12. Rapid tab switching (no typing between)
// ═══════════════════════════════════════════
console.log('── 12. Rapid tab switching ──');
for (let i = 0; i < 3; i++) {
await selectSession(page, sA);
await page.waitForTimeout(200);
await selectSession(page, sB);
await page.waitForTimeout(200);
await selectSession(page, sC);
await page.waitForTimeout(200);
}
st = await getState(page);
if (st.overlayText === '' && st.flushedOffset === 0) pass('Rapid switches — clean state');
else fail('Rapid switches', `text="${st.overlayText}", flushed=${st.flushedOffset}`);
// ═══════════════════════════════════════════
// 13. Backspace on flushed text (no pending)
// ═══════════════════════════════════════════
console.log('── 13. Backspace on flushed text ──');
await selectSession(page, sA);
await page.waitForTimeout(3000);
await focusTerminal(page);
await page.keyboard.type('abc', { delay: 20 });
await page.waitForTimeout(200);
await selectSession(page, sB);
await page.waitForTimeout(500);
await selectSession(page, sA);
await page.waitForTimeout(500);
st = await getState(page);
const flushedA2 = st.flushedOffset;
console.log(` Flushed offset for A: ${flushedA2}`);
await focusTerminal(page);
if (flushedA2 > 0) {
await page.keyboard.press('Backspace');
await page.waitForTimeout(300);
st = await getState(page);
if (st.flushedOffset === flushedA2 - 1) pass('Backspace decrements flushed offset');
else fail('Backspace flushed', `expected ${flushedA2 - 1}, got ${st.flushedOffset}`);
} else {
pass('Flushed offset was 0 — skip backspace flushed test');
}
// ═══════════════════════════════════════════
// 14. Overlay hidden when scrolled up
// ═══════════════════════════════════════════
console.log('── 14. Overlay hidden on scroll up ──');
await focusTerminal(page);
await page.keyboard.down('Control');
await page.keyboard.press('c');
await page.keyboard.up('Control');
await page.waitForTimeout(1000);
await focusTerminal(page);
await page.keyboard.type('scroll-test', { delay: 20 });
await page.waitForTimeout(200);
await page.evaluate(() => window.app?.terminal?.scrollToTop());
await page.waitForTimeout(300);
// Check if scroll actually happened (buffer may be too short for viewport)
const scrollState = await page.evaluate(() => {
const buf = window.app?.terminal?.buffer?.active;
return { viewportY: buf?.viewportY, baseY: buf?.baseY };
});
if (scrollState.viewportY === scrollState.baseY) {
// Buffer too short to scroll — skip scroll hide/show tests
pass('Scroll up hide — SKIP (buffer too short to scroll, viewportY===baseY)');
pass('Text preserved while scrolled — SKIP');
pass('Overlay reappears at bottom — SKIP');
console.log(` (viewportY=${scrollState.viewportY}, baseY=${scrollState.baseY})`);
} else {
st = await getState(page);
if (!st.overlayVisible) pass('Overlay hidden when scrolled up');
else fail('Scroll up hide', `visible=${st.overlayVisible}`);
if (st.overlayText === 'scroll-test') pass('Text preserved while scrolled');
else fail('Text during scroll', `text="${st.overlayText}"`);
await page.evaluate(() => window.app?.terminal?.scrollToBottom());
await page.waitForTimeout(500);
await page.keyboard.type('x', { delay: 20 });
await page.waitForTimeout(500);
st = await getState(page);
if (st.overlayVisible) pass('Overlay reappears at bottom');
else fail('Scroll bottom show', `visible=${st.overlayVisible}`);
}
// ═══════════════════════════════════════════
// 15. Double reload
// ═══════════════════════════════════════════
console.log('── 15. Double reload ──');
await page.reload({ waitUntil: 'domcontentloaded' });
await page.waitForSelector('.xterm-screen', { timeout: 10000 });
await page.waitForTimeout(1500);
await page.reload({ waitUntil: 'domcontentloaded' });
await page.waitForSelector('.xterm-screen', { timeout: 10000 });
await page.waitForTimeout(2000);
st = await getState(page);
if (st.overlayText === '') pass('Clean state after double reload');
else fail('Double reload', `text="${st.overlayText}"`);
await focusTerminal(page);
await page.keyboard.type('post-double-reload', { delay: 20 });
await page.waitForTimeout(200);
st = await getState(page);
if (st.overlayText === 'post-double-reload') pass('Input works after double reload');
else fail('Post double reload type', `text="${st.overlayText}"`);
await screenshot(page, '15-double-reload');
// ═══════════════════════════════════════════
// 16. Ctrl+C clears flushed offset too
// ═══════════════════════════════════════════
console.log('── 16. Ctrl+C clears flushed offsets ──');
// Clear current text
await page.keyboard.down('Control');
await page.keyboard.press('c');
await page.keyboard.up('Control');
await page.waitForTimeout(2000);
await focusTerminal(page);
await page.keyboard.type('flush-ctrl-c', { delay: 20 });
await page.waitForTimeout(200);
// Switch to flush
await selectSession(page, sB);
await page.waitForTimeout(500);
// Switch back
await selectSession(page, sA);
await page.waitForTimeout(500);
st = await getState(page);
const preFlushed = st.flushedOffset;
// Now Ctrl+C — should clear flushed offset
await focusTerminal(page);
await page.keyboard.down('Control');
await page.keyboard.press('c');
await page.keyboard.up('Control');
await page.waitForTimeout(500);
st = await getState(page);
if (st.flushedOffset === 0) pass('Ctrl+C clears flushed offset');
else fail('Ctrl+C flushed', `expected 0, got ${st.flushedOffset} (was ${preFlushed})`);
const aInMap = st.flushedOffsets[sA];
if (!aInMap) pass('Ctrl+C clears flushed offsets map for session');
else fail('Ctrl+C map', `A still in map: ${aInMap}`);
// ═══════════════════════════════════════════
// 17. JS errors check
// ═══════════════════════════════════════════
console.log('── 17. JS errors ──');
if (jsErrors.length === 0) pass('No JS errors during all tests');
else {
fail('JS errors found', `${jsErrors.length} errors`);
jsErrors.forEach(e => console.log(` ⚠️ ${e}`));
}
// ═══════════════════════════════════════════
// Summary
// ═══════════════════════════════════════════
console.log('\n═══════════════════════════════════');
const passed = results.filter(r => r.ok).length;
const failed = results.filter(r => !r.ok).length;
console.log(` ${passed} passed, ${failed} failed out of ${results.length} checks`);
if (failed > 0) {
console.log('\n Failures:');
results.filter(r => !r.ok).forEach(r => console.log(` ❌ ${r.name}: ${r.detail}`));
}
console.log('═══════════════════════════════════\n');
} finally {
await browser.close();
console.log(' Cleaning up sessions...');
await deleteSession(sA);
await deleteSession(sB);
await deleteSession(sC);
console.log(' Done.\n');
}
}
main().catch(e => { console.error(e); process.exit(1); });
-717
View File
@@ -1,717 +0,0 @@
/**
* Local Echo Overlay — Mobile-focused integration test.
*
* Tests:
* 1. Type text → verify overlay renders with correct font/position
* 2. Switch tabs → verify text is flushed and overlay clears
* 3. Switch back → type more → verify overlay still works
* 4. Backspace (delete chars) from old and new input
* 5. Font matching — overlay font vs terminal font comparison
* 6. Long text wrapping across lines
*
* Takes screenshots at every step for visual verification.
*
* Usage: node test/local-echo-mobile-test.mjs
*/
import { chromium } from 'playwright';
const PORT = 3000;
const BASE = `http://localhost:${PORT}`;
const SCREENSHOT_DIR = '/tmp/local-echo-mobile';
async function createSession(name) {
const resp = await fetch(`${BASE}/api/sessions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mode: 'shell', name })
});
if (!resp.ok) throw new Error(`Failed to create session '${name}': ${resp.status}`);
const data = await resp.json();
return data.session?.id || data.id;
}
async function deleteSession(id) {
await fetch(`${BASE}/api/sessions/${id}`, { method: 'DELETE' }).catch(() => {});
}
async function waitForReady(sessionId, timeoutMs = 10000) {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
const resp = await fetch(`${BASE}/api/sessions/${sessionId}`);
if (resp.ok) {
const data = await resp.json();
if (data.status === 'idle' || data.status === 'busy') return true;
}
await new Promise(r => setTimeout(r, 200));
}
return false;
}
/** Get overlay and terminal font/position info from the page */
async function getOverlayState(page) {
return page.evaluate(() => {
const app = window.app;
if (!app) return null;
// Find overlay container
const overlay = app._localEchoOverlay?.overlay;
const overlayVisible = overlay && overlay.style.display !== 'none';
const overlayText = app._localEchoOverlay?.pendingText || '';
// Get overlay font styles from first span child
let overlayFont = null;
if (overlay) {
const span = overlay.querySelector('span');
if (span) {
const cs = getComputedStyle(span);
overlayFont = {
fontFamily: cs.fontFamily,
fontSize: cs.fontSize,
fontWeight: cs.fontWeight,
color: cs.color,
letterSpacing: cs.letterSpacing,
};
}
}
// Get terminal font styles from .xterm-rows
let terminalFont = null;
const rows = document.querySelector('.xterm-rows');
if (rows) {
// Find an actual character span in the rendered rows
const charSpan = rows.querySelector('span[style]') || rows.querySelector('span');
const cs = getComputedStyle(rows);
terminalFont = {
fontFamily: cs.fontFamily,
fontSize: cs.fontSize,
fontWeight: cs.fontWeight,
color: cs.color,
letterSpacing: cs.letterSpacing,
};
// Also get char-level font if available
if (charSpan) {
const charCs = getComputedStyle(charSpan);
terminalFont.charFontFamily = charCs.fontFamily;
terminalFont.charFontSize = charCs.fontSize;
}
}
// Get overlay DOM children info
const overlayChildren = [];
if (overlay) {
for (const child of overlay.children) {
const spans = child.querySelectorAll('span');
const text = Array.from(spans).map(s => s.textContent).join('');
overlayChildren.push({
text,
left: child.style.left,
top: child.style.top,
width: child.style.width,
spanCount: spans.length,
});
}
}
// Get overlay position
const overlayRect = overlay?.getBoundingClientRect();
return {
overlayVisible,
overlayText,
overlayFont,
terminalFont,
overlayChildren,
overlayRect: overlayRect ? {
x: Math.round(overlayRect.x),
y: Math.round(overlayRect.y),
width: Math.round(overlayRect.width),
height: Math.round(overlayRect.height),
} : null,
localEchoEnabled: app._localEchoEnabled,
activeSessionId: app.activeSessionId,
flushedOffset: app._localEchoOverlay?._flushedOffset || 0,
flushedOffsets: app._flushedOffsets ? Object.fromEntries(app._flushedOffsets) : {},
};
});
}
/** Get list of session tab IDs */
async function getSessionTabs(page) {
return page.evaluate(() => {
const tabs = document.querySelectorAll('.session-tab');
return Array.from(tabs).map(t => ({
id: t.dataset.id,
name: t.querySelector('.tab-name')?.textContent || t.textContent?.trim(),
active: t.classList.contains('active'),
}));
});
}
/** Click a session tab by ID */
async function clickTab(page, sessionId) {
await page.evaluate((id) => {
const tab = document.querySelector(`.session-tab[data-id="${id}"]`);
if (tab) tab.click();
}, sessionId);
await page.waitForTimeout(1500); // Wait for buffer load
}
async function screenshot(page, name) {
const path = `${SCREENSHOT_DIR}/${name}.png`;
await page.screenshot({ path, fullPage: false });
console.log(` 📸 ${path}`);
return path;
}
function compareFonts(overlayFont, terminalFont) {
if (!overlayFont || !terminalFont) return { match: false, reason: 'missing font data' };
const issues = [];
if (overlayFont.fontFamily !== terminalFont.fontFamily) {
issues.push(`fontFamily: overlay="${overlayFont.fontFamily}" vs terminal="${terminalFont.fontFamily}"`);
}
if (overlayFont.fontSize !== terminalFont.fontSize) {
issues.push(`fontSize: overlay="${overlayFont.fontSize}" vs terminal="${terminalFont.fontSize}"`);
}
if (overlayFont.fontWeight !== terminalFont.fontWeight) {
issues.push(`fontWeight: overlay="${overlayFont.fontWeight}" vs terminal="${terminalFont.fontWeight}"`);
}
if (overlayFont.letterSpacing !== terminalFont.letterSpacing) {
issues.push(`letterSpacing: overlay="${overlayFont.letterSpacing}" vs terminal="${terminalFont.letterSpacing}"`);
}
return { match: issues.length === 0, issues };
}
async function main() {
// Create screenshot directory
const { mkdirSync } = await import('fs');
try { mkdirSync(SCREENSHOT_DIR, { recursive: true }); } catch {}
console.log('=== Local Echo Overlay — Mobile Test ===\n');
// Create two shell sessions for tab-switching tests
console.log('Creating test sessions...');
const sessionA = await createSession('echo-test-A');
const sessionB = await createSession('echo-test-B');
console.log(` Session A: ${sessionA}`);
console.log(` Session B: ${sessionB}`);
const readyA = await waitForReady(sessionA);
const readyB = await waitForReady(sessionB);
if (!readyA || !readyB) {
console.error('Sessions failed to become ready');
await deleteSession(sessionA);
await deleteSession(sessionB);
process.exit(1);
}
console.log(' Both sessions ready\n');
// Launch browser with mobile-like viewport
const browser = await chromium.launch({
headless: true,
args: ['--window-size=390,844']
});
const context = await browser.newContext({
viewport: { width: 390, height: 844 },
userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1',
isMobile: true,
hasTouch: true,
});
const page = await context.newPage();
const errors = [];
page.on('console', msg => { if (msg.type() === 'error') errors.push(msg.text()); });
const results = [];
function pass(name) { results.push({ name, status: 'PASS' }); console.log(` ✅ PASS: ${name}`); }
function fail(name, detail) { results.push({ name, status: 'FAIL', detail }); console.log(` ❌ FAIL: ${name} — ${detail}`); }
function info(name, detail) { results.push({ name, status: 'INFO', detail }); console.log(` ℹ️ INFO: ${name} — ${detail}`); }
try {
// ── SETUP ──
console.log('Opening Codeman...');
await page.goto(BASE, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('.xterm-screen', { timeout: 10000 });
await page.waitForTimeout(2000);
// Enable local echo via settings
console.log('Enabling local echo...');
await page.evaluate(() => {
const settings = JSON.parse(localStorage.getItem('codeman-app-settings') || '{}');
settings.localEchoEnabled = true;
localStorage.setItem('codeman-app-settings', JSON.stringify(settings));
window.app?._updateLocalEchoState?.();
});
await page.waitForTimeout(300);
// Switch to session A
console.log('Switching to Session A...');
await clickTab(page, sessionA);
await page.waitForTimeout(1000);
// Focus terminal — use the hidden textarea (xterm-viewport intercepts pointer events)
await page.evaluate(() => {
const ta = document.querySelector('.xterm-helper-textarea');
if (ta) ta.focus();
});
await page.waitForTimeout(300);
let state = await getOverlayState(page);
if (state?.localEchoEnabled) {
pass('Local echo enabled');
} else {
fail('Local echo enabled', `localEchoEnabled=${state?.localEchoEnabled}`);
}
await screenshot(page, '01-initial-session-a');
// ── TEST 1: Type text on Session A ──
console.log('\n--- Test 1: Type text on Session A ---');
const textA = 'hello-from-session-a';
await page.keyboard.type(textA, { delay: 50 });
await page.waitForTimeout(500);
state = await getOverlayState(page);
await screenshot(page, '02-typed-text-session-a');
console.log(` Overlay visible: ${state?.overlayVisible}`);
console.log(` Overlay text: "${state?.overlayText}"`);
console.log(` Overlay children: ${state?.overlayChildren?.length}`);
if (state?.overlayVisible && state?.overlayText === textA) {
pass('Overlay shows typed text');
} else if (state?.overlayText === textA) {
info('Overlay has text but not visible', `visible=${state?.overlayVisible}`);
} else {
fail('Overlay shows typed text', `expected="${textA}" got="${state?.overlayText}"`);
}
// ── TEST 2: Font matching ──
// Compare overlay's cached font values against xterm.js options (always available,
// unlike .xterm-rows DOM spans which don't exist with WebGL/canvas renderer).
console.log('\n--- Test 2: Font matching ---');
const xtermFont = await page.evaluate(() => {
const t = window.app?.terminal;
if (!t) return null;
const overlay = window.app?._localEchoOverlay;
return {
xtermOpts: {
fontFamily: t.options.fontFamily,
fontSize: t.options.fontSize + 'px',
fontWeight: String(t.options.fontWeight || 'normal'),
},
overlayCached: {
fontFamily: overlay?._fontFamily,
fontSize: overlay?._fontSize,
fontWeight: String(overlay?._fontWeight || 'normal'),
},
};
});
console.log(` xterm opts: ${JSON.stringify(xtermFont?.xtermOpts)}`);
console.log(` overlay cached: ${JSON.stringify(xtermFont?.overlayCached)}`);
if (xtermFont) {
const fontIssues = [];
if (xtermFont.overlayCached.fontFamily !== xtermFont.xtermOpts.fontFamily)
fontIssues.push(`fontFamily: "${xtermFont.overlayCached.fontFamily}" vs "${xtermFont.xtermOpts.fontFamily}"`);
if (xtermFont.overlayCached.fontSize !== xtermFont.xtermOpts.fontSize)
fontIssues.push(`fontSize: "${xtermFont.overlayCached.fontSize}" vs "${xtermFont.xtermOpts.fontSize}"`);
if (xtermFont.overlayCached.fontWeight !== xtermFont.xtermOpts.fontWeight)
fontIssues.push(`fontWeight: "${xtermFont.overlayCached.fontWeight}" vs "${xtermFont.xtermOpts.fontWeight}"`);
if (fontIssues.length === 0) {
pass('Overlay font matches xterm.js options');
} else {
fail('Font mismatch vs xterm options', fontIssues.join('; '));
}
} else {
fail('Font comparison', 'Could not read xterm options');
}
// ── TEST 3: Backspace — delete some chars ──
console.log('\n--- Test 3: Backspace on Session A ---');
// Delete last 5 chars
for (let i = 0; i < 5; i++) {
await page.keyboard.press('Backspace');
await page.waitForTimeout(50);
}
await page.waitForTimeout(300);
state = await getOverlayState(page);
await screenshot(page, '03-after-backspace-session-a');
const expectedAfterBackspace = textA.slice(0, -5); // "hello-from-sess"
console.log(` Expected: "${expectedAfterBackspace}"`);
console.log(` Got: "${state?.overlayText}"`);
if (state?.overlayText === expectedAfterBackspace) {
pass('Backspace deletes from overlay correctly');
} else {
fail('Backspace result', `expected="${expectedAfterBackspace}" got="${state?.overlayText}"`);
}
// ── TEST 4: Switch to Session B (text should flush) ──
console.log('\n--- Test 4: Switch to Session B ---');
const textBeforeSwitch = state?.overlayText || '';
await clickTab(page, sessionB);
state = await getOverlayState(page);
await screenshot(page, '04-switched-to-session-b');
console.log(` Active session: ${state?.activeSessionId}`);
console.log(` Overlay visible: ${state?.overlayVisible}`);
console.log(` Overlay text: "${state?.overlayText}"`);
console.log(` Flushed offsets: ${JSON.stringify(state?.flushedOffsets)}`);
if (state?.activeSessionId === sessionB) {
pass('Switched to Session B');
} else {
fail('Tab switch', `active=${state?.activeSessionId}, expected=${sessionB}`);
}
if (!state?.overlayVisible && !state?.overlayText) {
pass('Overlay cleared on tab switch');
} else {
fail('Overlay not cleared', `visible=${state?.overlayVisible}, text="${state?.overlayText}"`);
}
// Check if text was flushed to Session A
if (state?.flushedOffsets?.[sessionA] > 0) {
pass(`Text flushed to Session A (${state.flushedOffsets[sessionA]} chars)`);
} else {
info('Flushed offsets', JSON.stringify(state?.flushedOffsets));
}
// ── TEST 5: Type on Session B ──
console.log('\n--- Test 5: Type on Session B ---');
await page.evaluate(() => { document.querySelector('.xterm-helper-textarea')?.focus(); });
await page.waitForTimeout(300);
const textB = 'hello-from-session-b';
await page.keyboard.type(textB, { delay: 50 });
await page.waitForTimeout(500);
state = await getOverlayState(page);
await screenshot(page, '05-typed-on-session-b');
console.log(` Overlay visible: ${state?.overlayVisible}`);
console.log(` Overlay text: "${state?.overlayText}"`);
if (state?.overlayVisible && state?.overlayText === textB) {
pass('Overlay works on Session B');
} else {
fail('Session B overlay', `expected="${textB}" got="${state?.overlayText}"`);
}
// ── TEST 6: Switch back to Session A ──
console.log('\n--- Test 6: Switch back to Session A ---');
await clickTab(page, sessionA);
state = await getOverlayState(page);
await screenshot(page, '06-back-to-session-a');
console.log(` Active session: ${state?.activeSessionId}`);
console.log(` Overlay visible: ${state?.overlayVisible}`);
console.log(` Overlay text: "${state?.overlayText}"`);
console.log(` Flushed offset on overlay: ${state?.flushedOffset}`);
console.log(` Flushed offsets map: ${JSON.stringify(state?.flushedOffsets)}`);
// Session A's text was flushed before we left — it should have been sent to PTY.
// The overlay should be clear (text was flushed, not pending anymore).
if (!state?.overlayText) {
pass('Session A overlay clear after return (text was flushed)');
} else {
info('Session A has overlay text after return', `"${state?.overlayText}"`);
}
// ── TEST 7: Type NEW text on Session A after returning ──
console.log('\n--- Test 7: Type new text after returning to Session A ---');
await page.evaluate(() => { document.querySelector('.xterm-helper-textarea')?.focus(); });
await page.waitForTimeout(300);
const textA2 = 'new-text-after-return';
await page.keyboard.type(textA2, { delay: 50 });
await page.waitForTimeout(500);
state = await getOverlayState(page);
await screenshot(page, '07-new-text-session-a');
console.log(` Overlay visible: ${state?.overlayVisible}`);
console.log(` Overlay text: "${state?.overlayText}"`);
console.log(` Flushed offset: ${state?.flushedOffset}`);
console.log(` Overlay children: ${JSON.stringify(state?.overlayChildren)}`);
if (state?.overlayText === textA2) {
pass('Can type new text after tab switch return');
} else {
fail('New text after return', `expected="${textA2}" got="${state?.overlayText}"`);
}
// ── TEST 8: Delete ALL chars with backspace ──
console.log('\n--- Test 8: Delete all chars with backspace ---');
const deleteCount = (state?.overlayText || '').length;
for (let i = 0; i < deleteCount + 3; i++) { // +3 extra to test over-delete
await page.keyboard.press('Backspace');
await page.waitForTimeout(30);
}
await page.waitForTimeout(300);
state = await getOverlayState(page);
await screenshot(page, '08-all-deleted');
console.log(` Overlay visible: ${state?.overlayVisible}`);
console.log(` Overlay text: "${state?.overlayText}"`);
// After deleting all typed (pending) text, overlay stays visible because
// flushed text still exists (3 extra backspaces only reduced flushed from 15→12).
// The overlay correctly shows flushed text so the user can keep backspacing.
if (state?.flushedOffset > 0 && state?.overlayVisible && !state?.overlayText) {
pass('Overlay visible with flushed text after deleting pending');
} else if (!state?.overlayVisible && !state?.overlayText && state?.flushedOffset === 0) {
pass('Overlay hidden after deleting all chars');
} else {
fail('Unexpected state after full delete', `visible=${state?.overlayVisible}, text="${state?.overlayText}", flushed=${state?.flushedOffset}`);
}
// Verify backspace-into-flushed-text: the 3 extra backspaces should have
// decremented flushedOffset from 15 to 12 and sent \x7f to PTY.
const expectedFlushedAfterDelete = 15 - 3; // original 15, minus 3 extras
console.log(` Flushed offset after delete: ${state?.flushedOffset} (expected ${expectedFlushedAfterDelete})`);
if (state?.flushedOffset === expectedFlushedAfterDelete) {
pass('Backspace into flushed text decrements offset correctly');
} else {
fail('Flushed offset after backspace', `expected ${expectedFlushedAfterDelete}, got ${state?.flushedOffset}`);
}
// ── TEST 9: Type, submit with Enter, verify overlay clears ──
console.log('\n--- Test 9: Enter submits and clears overlay ---');
const enterText = 'echo submit-test';
await page.keyboard.type(enterText, { delay: 50 });
await page.waitForTimeout(300);
state = await getOverlayState(page);
await screenshot(page, '09-before-enter');
if (state?.overlayText === enterText) {
pass('Text buffered before Enter');
} else {
info('Pre-enter text', `"${state?.overlayText}"`);
}
await page.keyboard.press('Enter');
await page.waitForTimeout(1000);
state = await getOverlayState(page);
await screenshot(page, '10-after-enter');
console.log(` Overlay visible: ${state?.overlayVisible}`);
console.log(` Overlay text: "${state?.overlayText}"`);
if (!state?.overlayVisible && !state?.overlayText) {
pass('Overlay cleared after Enter');
} else {
fail('Overlay not cleared after Enter', `visible=${state?.overlayVisible}, text="${state?.overlayText}"`);
}
// ── TEST 10: Rapid tab switching with pending text ──
console.log('\n--- Test 10: Rapid tab switching ---');
await page.keyboard.type('rapid-switch-text', { delay: 30 });
await page.waitForTimeout(200);
await screenshot(page, '11-before-rapid-switch');
// Rapid switch: A → B → A
await clickTab(page, sessionB);
await page.waitForTimeout(300);
await screenshot(page, '12-rapid-switch-to-b');
await clickTab(page, sessionA);
await page.waitForTimeout(300);
await screenshot(page, '13-rapid-switch-back-to-a');
state = await getOverlayState(page);
console.log(` After rapid switch — overlay text: "${state?.overlayText}"`);
console.log(` Flushed offsets: ${JSON.stringify(state?.flushedOffsets)}`);
// Text should have been flushed to PTY, overlay should be clear
if (!state?.overlayText || state?.overlayText === '') {
pass('Rapid switch: overlay clear after return');
} else {
fail('Rapid switch: overlay has stale text', `"${state?.overlayText}"`);
}
// ── TEST 11: Font detail check — pixel-level ──
console.log('\n--- Test 11: Detailed font comparison ---');
await page.keyboard.type('font-check', { delay: 50 });
await page.waitForTimeout(300);
const fontDetail = await page.evaluate(() => {
const app = window.app;
const overlay = app?._localEchoOverlay;
if (!overlay) return null;
// Overlay span
const oSpan = overlay.overlay.querySelector('span');
// Terminal character
const tRows = document.querySelector('.xterm-rows');
// Find a row with actual text
let tSpan = null;
if (tRows) {
const allSpans = tRows.querySelectorAll('span');
for (const s of allSpans) {
if (s.textContent?.trim()) { tSpan = s; break; }
}
}
const getDetailedFont = (el) => {
if (!el) return null;
const cs = getComputedStyle(el);
return {
fontFamily: cs.fontFamily,
fontSize: cs.fontSize,
fontWeight: cs.fontWeight,
fontStyle: cs.fontStyle,
letterSpacing: cs.letterSpacing,
lineHeight: cs.lineHeight,
color: cs.color,
textRendering: cs.textRendering,
webkitFontSmoothing: cs.webkitFontSmoothing || cs['-webkit-font-smoothing'],
fontKerning: cs.fontKerning,
fontVariantLigatures: cs.fontVariantLigatures,
};
};
// Also check xterm.js options
const xtermOpts = {
fontFamily: app.terminal?.options?.fontFamily,
fontSize: app.terminal?.options?.fontSize,
fontWeight: app.terminal?.options?.fontWeight,
letterSpacing: app.terminal?.options?.letterSpacing,
};
// Cell dimensions
let cellDims = null;
try {
const dims = app.terminal._core._renderService.dimensions;
cellDims = {
cellWidth: dims.css.cell.width,
cellHeight: dims.css.cell.height,
};
} catch {}
return {
overlaySpan: getDetailedFont(oSpan),
terminalSpan: getDetailedFont(tSpan),
terminalRows: getDetailedFont(tRows),
xtermOptions: xtermOpts,
cellDims,
// Check overlay cached values vs actual
overlayCached: {
fontFamily: overlay._fontFamily,
fontSize: overlay._fontSize,
fontWeight: overlay._fontWeight,
color: overlay._color,
letterSpacing: overlay._letterSpacing,
bg: overlay._bg,
},
};
});
await screenshot(page, '14-font-detail-check');
console.log(' xterm.js options:', JSON.stringify(fontDetail?.xtermOptions, null, 4));
console.log(' Cell dims:', JSON.stringify(fontDetail?.cellDims, null, 4));
console.log(' Overlay cached:', JSON.stringify(fontDetail?.overlayCached, null, 4));
console.log(' Overlay span computed:', JSON.stringify(fontDetail?.overlaySpan, null, 4));
console.log(' Terminal span computed:', JSON.stringify(fontDetail?.terminalSpan, null, 4));
console.log(' Terminal rows computed:', JSON.stringify(fontDetail?.terminalRows, null, 4));
// Check specific mismatches
if (fontDetail?.overlaySpan && fontDetail?.terminalSpan) {
const o = fontDetail.overlaySpan;
const t = fontDetail.terminalSpan;
const mismatches = [];
for (const key of ['fontFamily', 'fontSize', 'fontWeight', 'letterSpacing', 'fontKerning']) {
if (o[key] !== t[key]) {
mismatches.push(`${key}: overlay="${o[key]}" vs terminal="${t[key]}"`);
}
}
if (mismatches.length === 0) {
pass('Detailed font comparison matches');
} else {
fail('Font mismatches found', mismatches.join('; '));
}
} else {
info('Font detail check', 'Could not get computed styles for comparison');
}
// Clean up the typed text
await page.keyboard.press('Backspace');
await page.waitForTimeout(50);
for (let i = 0; i < 'font-check'.length; i++) {
await page.keyboard.press('Backspace');
await page.waitForTimeout(20);
}
// ── TEST 12: Long text that wraps ──
console.log('\n--- Test 12: Long text wrapping ---');
// Type enough to wrap past terminal width
const longText = 'A'.repeat(80) + 'B'.repeat(40); // 120 chars — likely wraps on mobile
await page.keyboard.type(longText, { delay: 5 });
await page.waitForTimeout(500);
state = await getOverlayState(page);
await screenshot(page, '15-long-text-wrap');
console.log(` Overlay text length: ${state?.overlayText?.length}`);
console.log(` Overlay children (lines): ${state?.overlayChildren?.length}`);
if (state?.overlayChildren) {
for (const [i, child] of state.overlayChildren.entries()) {
console.log(` Line ${i}: "${child.text}" (${child.spanCount} spans, left=${child.left}, top=${child.top})`);
}
}
if (state?.overlayText === longText) {
pass('Long text stored correctly');
} else {
fail('Long text storage', `expected ${longText.length} chars, got ${state?.overlayText?.length}`);
}
if (state?.overlayChildren && state.overlayChildren.length > 1) {
pass(`Long text wraps into ${state.overlayChildren.length} lines`);
} else {
fail('Long text wrapping', `only ${state?.overlayChildren?.length} line(s)`);
}
// Delete the long text
await page.keyboard.press('Enter');
await page.waitForTimeout(500);
// Final screenshot
await screenshot(page, '16-final-state');
// ── SUMMARY ──
console.log('\n=== SUMMARY ===');
const passes = results.filter(r => r.status === 'PASS').length;
const fails = results.filter(r => r.status === 'FAIL').length;
const infos = results.filter(r => r.status === 'INFO').length;
console.log(` ${passes} passed, ${fails} failed, ${infos} info`);
console.log(` Screenshots saved to ${SCREENSHOT_DIR}/`);
if (fails > 0) {
console.log('\n FAILURES:');
for (const r of results.filter(r => r.status === 'FAIL')) {
console.log(` ❌ ${r.name}: ${r.detail}`);
}
}
if (errors.length > 0) {
console.log(`\n Console errors (${errors.length}):`);
for (const e of errors.slice(0, 10)) console.log(` ${e}`);
}
} finally {
await browser.close();
console.log('\nCleaning up sessions...');
await deleteSession(sessionA);
await deleteSession(sessionB);
}
}
main().catch(err => {
console.error('Test failed:', err);
process.exit(1);
});
-398
View File
@@ -1,398 +0,0 @@
/**
* Local Echo Overlay — Scroll and position tests.
*
* Focused on:
* 1. Overlay hidden when scrolled up (prompt not at viewport bottom)
* 2. Overlay reappears when scrolled back to bottom
* 3. Overlay position correct after Ink redraws move the prompt
* 4. Overlay cursor block position matches text end
* 5. Multiple Enter submits — flushed offset doesn't carry across prompts
* 6. Backspace past all pending + all flushed = clean state
*
* Usage: node test/local-echo-scroll-test.mjs
*/
import { chromium } from 'playwright';
const PORT = 3000;
const BASE = `http://localhost:${PORT}`;
const SCREENSHOT_DIR = '/tmp/local-echo-scroll';
async function createSession(name) {
const resp = await fetch(`${BASE}/api/sessions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mode: 'claude', name })
});
if (!resp.ok) throw new Error(`Failed to create session '${name}': ${resp.status}`);
const data = await resp.json();
return data.session?.id || data.id;
}
async function deleteSession(id) {
await fetch(`${BASE}/api/sessions/${id}`, { method: 'DELETE' }).catch(() => {});
}
async function waitForReady(sessionId, timeoutMs = 15000) {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
const resp = await fetch(`${BASE}/api/sessions/${sessionId}`);
if (resp.ok) {
const data = await resp.json();
if (data.status === 'idle' || data.status === 'busy') return true;
}
await new Promise(r => setTimeout(r, 300));
}
return false;
}
async function getState(page) {
return page.evaluate(() => {
const app = window.app;
if (!app) return null;
const overlay = app._localEchoOverlay;
const buf = app.terminal?.buffer?.active;
return {
overlayVisible: overlay?.overlay?.style.display !== 'none',
overlayText: overlay?.pendingText || '',
flushedOffset: overlay?._flushedOffset || 0,
flushedOffsets: app._flushedOffsets ? Object.fromEntries(app._flushedOffsets) : {},
localEchoEnabled: app._localEchoEnabled,
activeSessionId: app.activeSessionId,
// Scroll state
viewportY: buf?.viewportY,
baseY: buf?.baseY,
isAtBottom: buf ? buf.viewportY === buf.baseY : null,
terminalRows: app.terminal?.rows,
// Overlay children
overlayChildren: (() => {
if (!overlay?.overlay) return [];
return Array.from(overlay.overlay.children).map(c => ({
text: Array.from(c.querySelectorAll('span')).map(s => s.textContent).join(''),
left: c.style.left,
top: c.style.top,
}));
})(),
};
});
}
async function focusTerminal(page) {
await page.evaluate(() => { document.querySelector('.xterm-helper-textarea')?.focus(); });
await page.waitForTimeout(200);
}
async function screenshot(page, name) {
const path = `${SCREENSHOT_DIR}/${name}.png`;
await page.screenshot({ path, fullPage: false });
console.log(` 📸 ${path}`);
}
const results = [];
function pass(name) { results.push({ name, status: 'PASS' }); console.log(` ✅ PASS: ${name}`); }
function fail(name, detail) { results.push({ name, status: 'FAIL', detail }); console.log(` ❌ FAIL: ${name} — ${detail}`); }
function info(name, detail) { results.push({ name, status: 'INFO', detail }); console.log(` ℹ️ INFO: ${name} — ${detail}`); }
async function main() {
const { mkdirSync } = await import('fs');
try { mkdirSync(SCREENSHOT_DIR, { recursive: true }); } catch {}
console.log('=== Local Echo — Scroll & Position Tests ===\n');
const sessionA = await createSession('scroll-test-A');
const sessionB = await createSession('scroll-test-B');
console.log(` Session A: ${sessionA}`);
console.log(` Session B: ${sessionB}`);
if (!await waitForReady(sessionA) || !await waitForReady(sessionB)) {
console.error('Sessions failed to become ready');
await deleteSession(sessionA);
await deleteSession(sessionB);
process.exit(1);
}
console.log(' Ready\n');
const browser = await chromium.launch({ headless: true, args: ['--window-size=390,844'] });
const context = await browser.newContext({
viewport: { width: 390, height: 844 },
userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0)',
isMobile: true, hasTouch: true,
});
const page = await context.newPage();
try {
await page.goto(BASE, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('.xterm-screen', { timeout: 10000 });
await page.waitForTimeout(2000);
// Enable local echo
await page.evaluate(() => {
const s = JSON.parse(localStorage.getItem('codeman-app-settings') || '{}');
s.localEchoEnabled = true;
localStorage.setItem('codeman-app-settings', JSON.stringify(s));
window.app?._updateLocalEchoState?.();
});
// Select session A
await page.evaluate((id) => {
const t = document.querySelector(`.session-tab[data-id="${id}"]`);
if (t) t.click();
}, sessionA);
await page.waitForTimeout(1500);
await focusTerminal(page);
// ══════════════════════════════════════════
// TEST 1: Overlay hidden when scrolled up
// ══════════════════════════════════════════
console.log('--- Test 1: Overlay hidden when scrolled up ---');
// Generate some content so we can scroll
await page.keyboard.type('just say ok', { delay: 20 });
await page.keyboard.press('Enter');
await page.waitForTimeout(4000); // Wait for response
await focusTerminal(page);
await page.keyboard.type('test-scroll-text', { delay: 30 });
await page.waitForTimeout(300);
let state = await getState(page);
console.log(` At bottom: isAtBottom=${state?.isAtBottom}, viewportY=${state?.viewportY}, baseY=${state?.baseY}`);
await screenshot(page, '01-at-bottom-with-text');
if (state?.overlayVisible && state?.overlayText === 'test-scroll-text') {
pass('Overlay visible at bottom with text');
} else {
fail('Overlay at bottom', `visible=${state?.overlayVisible}, text="${state?.overlayText}"`);
}
// Scroll to top
await page.evaluate(() => { window.app?.terminal?.scrollToTop(); });
await page.waitForTimeout(300);
state = await getState(page);
console.log(` After scroll up: isAtBottom=${state?.isAtBottom}, viewportY=${state?.viewportY}, baseY=${state?.baseY}`);
console.log(` Overlay visible: ${state?.overlayVisible}, text: "${state?.overlayText}"`);
await screenshot(page, '02-scrolled-up');
if (state?.isAtBottom) {
// Buffer too short to scroll — skip this check
info('Overlay scroll-up test skipped', 'buffer too short to scroll (viewportY===baseY)');
} else if (!state?.overlayVisible) {
pass('Overlay hidden when scrolled up');
} else {
fail('Overlay visible when scrolled up', `visible=${state?.overlayVisible}`);
}
// Text should be preserved even though overlay is hidden
if (state?.overlayText === 'test-scroll-text') {
pass('Overlay text preserved while hidden');
} else {
fail('Overlay text lost during scroll', `"${state?.overlayText}"`);
}
// ══════════════════════════════════════════
// TEST 2: Overlay reappears at bottom
// ══════════════════════════════════════════
console.log('\n--- Test 2: Overlay reappears when scrolled back ---');
await page.evaluate(() => { window.app?.terminal?.scrollToBottom(); });
await page.waitForTimeout(300);
// Type one more char to trigger re-render
await page.keyboard.type('!', { delay: 30 });
await page.waitForTimeout(300);
state = await getState(page);
console.log(` At bottom: isAtBottom=${state?.isAtBottom}`);
console.log(` Overlay visible: ${state?.overlayVisible}, text: "${state?.overlayText}"`);
await screenshot(page, '03-back-to-bottom');
if (state?.overlayVisible && state?.overlayText === 'test-scroll-text!') {
pass('Overlay reappears at bottom with preserved text');
} else {
info('Overlay after scroll back', `visible=${state?.overlayVisible}, text="${state?.overlayText}"`);
}
// Clear
await page.keyboard.press('Control+c');
await page.waitForTimeout(500);
// ══════════════════════════════════════════
// TEST 3: Backspace deletes ALL flushed text back to zero
// ══════════════════════════════════════════
console.log('\n--- Test 3: Full backspace through pending + flushed ---');
await focusTerminal(page);
await page.keyboard.type('abcdef', { delay: 20 }); // 6 chars pending
await page.waitForTimeout(200);
// Flush by tab switch
await page.evaluate((id) => {
document.querySelector(`.session-tab[data-id="${id}"]`)?.click();
}, sessionB);
await page.waitForTimeout(1000);
// Switch back
await page.evaluate((id) => {
document.querySelector(`.session-tab[data-id="${id}"]`)?.click();
}, sessionA);
await page.waitForTimeout(1000);
await focusTerminal(page);
// Type 3 more chars
await page.keyboard.type('xyz', { delay: 20 });
await page.waitForTimeout(200);
state = await getState(page);
console.log(` State: pending="${state?.overlayText}", flushed=${state?.flushedOffset}`);
// Now backspace 3 (pending "xyz") + 6 (flushed "abcdef") + 2 extra = 11
for (let i = 0; i < 11; i++) {
await page.keyboard.press('Backspace');
await page.waitForTimeout(40);
}
await page.waitForTimeout(300);
state = await getState(page);
console.log(` After full delete: pending="${state?.overlayText}", flushed=${state?.flushedOffset}, map=${JSON.stringify(state?.flushedOffsets)}`);
await screenshot(page, '04-full-backspace');
if (state?.flushedOffset === 0 && !state?.overlayText) {
pass('Full backspace clears both pending and flushed');
} else {
fail('Full backspace', `pending="${state?.overlayText}", flushed=${state?.flushedOffset}`);
}
if (!state?.flushedOffsets?.[sessionA]) {
pass('Flushed offsets Map cleared after full backspace');
} else {
fail('Map not cleared', JSON.stringify(state?.flushedOffsets));
}
// ══════════════════════════════════════════
// TEST 4: Type → Enter → type again — no stale offset
// ══════════════════════════════════════════
console.log('\n--- Test 4: Enter resets state cleanly ---');
await focusTerminal(page);
await page.keyboard.type('first-prompt', { delay: 20 });
await page.waitForTimeout(200);
await page.keyboard.press('Enter');
await page.waitForTimeout(2000); // Wait for Claude response
// Now type on the NEW prompt
await focusTerminal(page);
await page.keyboard.type('second-prompt', { delay: 20 });
await page.waitForTimeout(300);
state = await getState(page);
console.log(` After Enter+retype: pending="${state?.overlayText}", flushed=${state?.flushedOffset}`);
await screenshot(page, '05-after-enter-retype');
if (state?.overlayText === 'second-prompt' && state?.flushedOffset === 0) {
pass('Clean state after Enter + retype');
} else {
fail('Stale state after Enter', `pending="${state?.overlayText}", flushed=${state?.flushedOffset}`);
}
// Clean up
await page.keyboard.press('Control+c');
await page.waitForTimeout(500);
// ══════════════════════════════════════════
// TEST 5: Overlay cursor position matches text end
// ══════════════════════════════════════════
console.log('\n--- Test 5: Cursor block position ---');
await focusTerminal(page);
await page.keyboard.type('cursor-pos', { delay: 30 }); // 10 chars
await page.waitForTimeout(300);
state = await getState(page);
const children = state?.overlayChildren || [];
console.log(` Children: ${JSON.stringify(children)}`);
// Should have 2 children: line div + cursor span
// Cursor should be at position (startCol + 10) * cellW
if (children.length >= 2) {
const cursorChild = children[children.length - 1];
// Cursor text should be empty (it's a block cursor, not a character)
if (cursorChild.text === '') {
pass('Cursor block present at end of text');
} else {
fail('Cursor block', `expected empty text, got "${cursorChild.text}"`);
}
// Check cursor is right after the text
const textChild = children[0];
const textLeft = parseInt(textChild.left);
const cellW = 6; // from previous tests
const expectedCursorLeft = textLeft + 10 * cellW; // 10 chars of "cursor-pos"
const actualCursorLeft = parseInt(cursorChild.left);
console.log(` Text left: ${textLeft}px, cursor left: ${actualCursorLeft}px, expected: ${expectedCursorLeft}px`);
if (actualCursorLeft === expectedCursorLeft) {
pass('Cursor positioned correctly after text');
} else {
fail('Cursor position', `expected ${expectedCursorLeft}px, got ${actualCursorLeft}px`);
}
} else {
fail('Overlay children count', `expected >=2, got ${children.length}`);
}
await screenshot(page, '06-cursor-position');
// Clean up
await page.keyboard.press('Control+c');
await page.waitForTimeout(500);
// ══════════════════════════════════════════
// TEST 6: Type while Claude is busy (working status)
// ══════════════════════════════════════════
console.log('\n--- Test 6: Type while Claude is busy ---');
await focusTerminal(page);
await page.keyboard.type('generate a long response with lots of text', { delay: 10 });
await page.keyboard.press('Enter');
await page.waitForTimeout(1000); // Claude starts responding
// Type while Claude is outputting
await page.keyboard.type('typing-while-busy', { delay: 30 });
await page.waitForTimeout(300);
state = await getState(page);
console.log(` While busy: pending="${state?.overlayText}", visible=${state?.overlayVisible}`);
await screenshot(page, '07-typing-while-busy');
if (state?.overlayText === 'typing-while-busy') {
pass('Can type while Claude is busy');
} else {
info('Typing while busy', `"${state?.overlayText}"`);
}
// Clean up
await page.keyboard.press('Control+c');
await page.waitForTimeout(500);
// Final screenshot
await screenshot(page, '08-final');
// ══════════════════════════════════════════
// SUMMARY
// ══════════════════════════════════════════
console.log('\n=== SUMMARY ===');
const passes = results.filter(r => r.status === 'PASS').length;
const fails = results.filter(r => r.status === 'FAIL').length;
const infos = results.filter(r => r.status === 'INFO').length;
console.log(` ${passes} passed, ${fails} failed, ${infos} info`);
if (fails > 0) {
console.log('\n FAILURES:');
for (const r of results.filter(r => r.status === 'FAIL')) {
console.log(` ❌ ${r.name}: ${r.detail}`);
}
}
} finally {
await browser.close();
console.log('\nCleaning up...');
await deleteSession(sessionA);
await deleteSession(sessionB);
}
}
main().catch(err => {
console.error('Test failed:', err);
process.exit(1);
});
-516
View File
@@ -1,516 +0,0 @@
/**
* Local Echo Overlay — Tab Switch Bug Reproduction Test
*
* Reproduces the specific bug: switch tabs, switch back, type a space →
* previous input disappears, cursor moves to start.
*
* Port: 3000 (runs against live dev server)
*
* Usage: node test/local-echo-tabswitch-test.mjs
*/
import { chromium } from 'playwright';
import { mkdirSync } from 'fs';
const PORT = 3000;
const BASE = `http://localhost:${PORT}`;
const SCREENSHOT_DIR = '/tmp/local-echo-tabswitch';
const results = [];
const jsErrors = [];
function pass(name) { results.push({ name, ok: true }); console.log(` ✓ PASS: ${name}`); }
function fail(name, detail) { results.push({ name, ok: false, detail }); console.log(` ✗ FAIL: ${name} — ${detail}`); }
async function createSession(name) {
const resp = await fetch(`${BASE}/api/sessions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mode: 'shell', name })
});
if (!resp.ok) throw new Error(`Failed to create session '${name}': ${resp.status}`);
const data = await resp.json();
return data.session?.id || data.id;
}
async function deleteSession(id) {
await fetch(`${BASE}/api/sessions/${id}`, { method: 'DELETE' }).catch(() => {});
}
async function waitForReady(sessionId, timeoutMs = 15000) {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
const resp = await fetch(`${BASE}/api/sessions/${sessionId}`);
if (resp.ok) {
const data = await resp.json();
if (data.status === 'idle' || data.status === 'busy') return true;
}
await new Promise(r => setTimeout(r, 200));
}
return false;
}
async function getOverlayState(page) {
return page.evaluate(() => {
const app = window.app;
if (!app) return null;
const overlay = app._localEchoOverlay;
const overlayEl = overlay?.overlay;
// Get displayed text from overlay DOM children
let displayedText = '';
if (overlayEl) {
for (const child of overlayEl.children) {
// Skip cursor element (no inner spans with text or single span with no text)
const spans = child.querySelectorAll('span');
const text = Array.from(spans).map(s => s.textContent).join('');
displayedText += text;
}
}
return {
overlayVisible: overlayEl ? overlayEl.style.display !== 'none' : false,
pendingText: overlay?.pendingText || '',
flushedOffset: overlay?._flushedOffset || 0,
flushedText: overlay?._flushedText || '',
displayedText: displayedText.replace(/\u00a0/g, ' '), // normalize nbsp
localEchoEnabled: app._localEchoEnabled,
activeSessionId: app.activeSessionId,
flushedOffsetsMap: app._flushedOffsets ? Object.fromEntries(app._flushedOffsets) : {},
flushedTextsMap: app._flushedTexts ? Object.fromEntries(app._flushedTexts) : {},
lastPromptPos: overlay?._lastPromptPos || null,
overlayChildCount: overlayEl?.children?.length || 0,
};
});
}
async function screenshot(page, name) {
const path = `${SCREENSHOT_DIR}/${name}.png`;
await page.screenshot({ path, fullPage: false });
console.log(` 📸 ${path}`);
}
async function clickTab(page, sessionId) {
await page.evaluate((id) => {
const tab = document.querySelector(`.session-tab[data-id="${id}"]`);
if (tab) tab.click();
}, sessionId);
}
async function focusTerminal(page) {
await page.evaluate(() => {
document.querySelector('.xterm-helper-textarea')?.focus();
});
await page.waitForTimeout(200);
}
async function main() {
try { mkdirSync(SCREENSHOT_DIR, { recursive: true }); } catch {}
console.log('=== Local Echo Tab Switch Bug Test ===\n');
// Create two shell sessions
console.log('Creating test sessions...');
const sessionA = await createSession('ts-test-A');
const sessionB = await createSession('ts-test-B');
console.log(` Session A: ${sessionA}`);
console.log(` Session B: ${sessionB}`);
const readyA = await waitForReady(sessionA);
const readyB = await waitForReady(sessionB);
if (!readyA || !readyB) {
console.error('Sessions failed to become ready');
await deleteSession(sessionA);
await deleteSession(sessionB);
process.exit(1);
}
console.log(' Both sessions ready\n');
const browser = await chromium.launch({
headless: true,
args: ['--window-size=390,844']
});
const context = await browser.newContext({
viewport: { width: 390, height: 844 },
userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15',
isMobile: true,
hasTouch: true,
});
const page = await context.newPage();
page.on('console', msg => { if (msg.type() === 'error') jsErrors.push(msg.text()); });
// Install instrumentation to trace flushed state clearing
await page.exposeFunction('__traceFlushedClear', (source, data) => {
console.log(` 🔍 TRACE: flushed state cleared from "${source}" — data=${data}`);
});
try {
// ── SETUP ──
console.log('Opening Codeman...');
await page.goto(BASE, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('.xterm-screen', { timeout: 10000 });
await page.waitForTimeout(2500);
// Enable local echo
console.log('Enabling local echo...');
await page.evaluate(() => {
const key = window.app?.getSettingsStorageKey?.() || 'codeman-app-settings';
const settings = JSON.parse(localStorage.getItem(key) || '{}');
settings.localEchoEnabled = true;
localStorage.setItem(key, JSON.stringify(settings));
if (window.app?._cachedAppSettings) window.app._cachedAppSettings.localEchoEnabled = true;
window.app?._updateLocalEchoState?.();
});
await page.waitForTimeout(300);
// Install tracing hooks inside the page to catch when flushed state is cleared
await page.evaluate(() => {
const app = window.app;
if (!app) return;
// Patch the onData handler to trace control char clearing
// We can't directly patch the inner function, but we can watch the Maps
const origDelete = Map.prototype.delete;
const flushedOffsets = app._flushedOffsets;
const flushedTexts = app._flushedTexts;
// Trace overlay.clear()
if (app._localEchoOverlay) {
const origClear = app._localEchoOverlay.clear.bind(app._localEchoOverlay);
app._localEchoOverlay.clear = function() {
const stack = new Error().stack;
// Extract caller info
const callerLine = stack.split('\n')[2]?.trim() || 'unknown';
console.warn(`[TRACE] overlay.clear() called from: ${callerLine}`);
window.__localEchoClearLog = window.__localEchoClearLog || [];
window.__localEchoClearLog.push({
time: Date.now(),
source: 'overlay.clear()',
caller: callerLine,
hadFlushed: this._flushedOffset > 0,
hadPending: this.pendingText.length > 0,
});
return origClear();
};
}
});
// Switch to session A
console.log('Switching to Session A...');
await clickTab(page, sessionA);
await page.waitForTimeout(2000);
await focusTerminal(page);
let state = await getOverlayState(page);
console.log(` Local echo enabled: ${state?.localEchoEnabled}`);
if (!state?.localEchoEnabled) {
fail('Local echo not enabled', `localEchoEnabled=${state?.localEchoEnabled}`);
return;
}
pass('Local echo enabled');
// ── STEP 1: Type text in Session A ──
console.log('\n--- Step 1: Type "hello world" in Session A ---');
await page.keyboard.type('hello world', { delay: 50 });
await page.waitForTimeout(500);
state = await getOverlayState(page);
await screenshot(page, '01-typed-in-A');
console.log(` pendingText: "${state?.pendingText}"`);
console.log(` flushedOffset: ${state?.flushedOffset}`);
console.log(` overlayVisible: ${state?.overlayVisible}`);
console.log(` displayedText: "${state?.displayedText}"`);
if (state?.pendingText === 'hello world') {
pass('Text typed in Session A');
} else {
fail('Text in Session A', `expected "hello world", got "${state?.pendingText}"`);
}
// ── STEP 2: Switch to Session B ──
console.log('\n--- Step 2: Switch to Session B ---');
// Clear trace log
await page.evaluate(() => { window.__localEchoClearLog = []; });
await clickTab(page, sessionB);
await page.waitForTimeout(2500);
await focusTerminal(page);
state = await getOverlayState(page);
await screenshot(page, '02-switched-to-B');
console.log(` activeSession: ${state?.activeSessionId}`);
console.log(` pendingText: "${state?.pendingText}"`);
console.log(` flushedOffset: ${state?.flushedOffset}`);
console.log(` flushedOffsetsMap: ${JSON.stringify(state?.flushedOffsetsMap)}`);
console.log(` flushedTextsMap: ${JSON.stringify(state?.flushedTextsMap)}`);
if (state?.activeSessionId === sessionB) {
pass('Switched to Session B');
} else {
fail('Tab switch to B', `activeSession=${state?.activeSessionId}`);
}
// Check that A's text was stored in flushed maps
const flushedForA = state?.flushedOffsetsMap?.[sessionA];
if (flushedForA === 11) { // "hello world" = 11 chars
pass(`Session A text flushed (${flushedForA} chars)`);
} else {
fail('Flushed offset for A', `expected 11, got ${flushedForA}`);
}
// ── STEP 3: Type in Session B ──
console.log('\n--- Step 3: Type "typing in B" in Session B ---');
await page.keyboard.type('typing in B', { delay: 50 });
await page.waitForTimeout(500);
state = await getOverlayState(page);
await screenshot(page, '03-typed-in-B');
console.log(` pendingText: "${state?.pendingText}"`);
console.log(` flushedOffsetsMap: ${JSON.stringify(state?.flushedOffsetsMap)}`);
if (state?.pendingText === 'typing in B') {
pass('Text typed in Session B');
} else {
fail('Text in Session B', `expected "typing in B", got "${state?.pendingText}"`);
}
// Verify A's flushed state is still in the Maps
if (state?.flushedOffsetsMap?.[sessionA] === 11) {
pass('Session A flushed state preserved while on B');
} else {
fail('A flushed state while on B', `expected 11, got ${state?.flushedOffsetsMap?.[sessionA]}`);
}
// ── STEP 4: Switch back to Session A ──
console.log('\n--- Step 4: Switch back to Session A ---');
await page.evaluate(() => { window.__localEchoClearLog = []; });
await clickTab(page, sessionA);
await page.waitForTimeout(3000); // Wait for full buffer load
state = await getOverlayState(page);
await screenshot(page, '04-back-to-A-before-typing');
console.log(` activeSession: ${state?.activeSessionId}`);
console.log(` pendingText: "${state?.pendingText}"`);
console.log(` flushedOffset: ${state?.flushedOffset}`);
console.log(` flushedText: "${state?.flushedText}"`);
console.log(` flushedOffsetsMap: ${JSON.stringify(state?.flushedOffsetsMap)}`);
console.log(` overlayVisible: ${state?.overlayVisible}`);
console.log(` lastPromptPos: ${JSON.stringify(state?.lastPromptPos)}`);
console.log(` displayedText: "${state?.displayedText}"`);
// Check trace log
const clearLog = await page.evaluate(() => window.__localEchoClearLog || []);
console.log(` Clear log entries: ${clearLog.length}`);
for (const entry of clearLog) {
console.log(` - ${entry.source} @ ${entry.caller} (hadFlushed=${entry.hadFlushed}, hadPending=${entry.hadPending})`);
}
if (state?.activeSessionId === sessionA) {
pass('Switched back to Session A');
} else {
fail('Tab switch back to A', `activeSession=${state?.activeSessionId}`);
}
// KEY CHECK: flushed state should be restored
if (state?.flushedOffset === 11) {
pass('Flushed offset restored on overlay');
} else {
fail('Flushed offset on overlay', `expected 11, got ${state?.flushedOffset}`);
}
if (state?.flushedText === 'hello world') {
pass('Flushed text restored on overlay');
} else {
fail('Flushed text on overlay', `expected "hello world", got "${state?.flushedText}"`);
}
// ── STEP 5: THE BUG — Type a space after switching back ──
console.log('\n--- Step 5: Type a SPACE after returning to Session A ---');
await focusTerminal(page);
// Record state RIGHT BEFORE typing
const preTypeState = await getOverlayState(page);
console.log(` PRE-TYPE state:`);
console.log(` pendingText: "${preTypeState?.pendingText}"`);
console.log(` flushedOffset: ${preTypeState?.flushedOffset}`);
console.log(` flushedText: "${preTypeState?.flushedText}"`);
console.log(` overlayVisible: ${preTypeState?.overlayVisible}`);
console.log(` localEchoEnabled: ${preTypeState?.localEchoEnabled}`);
await page.keyboard.type(' ', { delay: 0 }); // Type single space
await page.waitForTimeout(500);
state = await getOverlayState(page);
await screenshot(page, '05-typed-space-after-return');
console.log(` POST-TYPE state:`);
console.log(` pendingText: "${state?.pendingText}"`);
console.log(` flushedOffset: ${state?.flushedOffset}`);
console.log(` flushedText: "${state?.flushedText}"`);
console.log(` overlayVisible: ${state?.overlayVisible}`);
console.log(` displayedText: "${state?.displayedText}"`);
console.log(` overlayChildCount: ${state?.overlayChildCount}`);
// THE CRITICAL ASSERTION: flushed text should still be tracked
if (state?.flushedOffset === 11) {
pass('Flushed offset preserved after typing space');
} else {
fail('Flushed offset after space', `expected 11, got ${state?.flushedOffset}`);
}
// The displayed text should include BOTH flushed text AND new space
const expectedDisplay = 'hello world ';
if (state?.displayedText?.startsWith('hello world')) {
pass('Displayed text includes flushed + new text');
} else {
fail('Displayed text', `expected starts with "hello world", got "${state?.displayedText}"`);
}
if (state?.overlayVisible) {
pass('Overlay visible after typing');
} else {
fail('Overlay visibility', 'overlay is hidden after typing');
}
// ── STEP 6: Type more characters ──
console.log('\n--- Step 6: Type "more" after the space ---');
await page.keyboard.type('more', { delay: 50 });
await page.waitForTimeout(500);
state = await getOverlayState(page);
await screenshot(page, '06-typed-more-after-space');
console.log(` pendingText: "${state?.pendingText}"`);
console.log(` flushedOffset: ${state?.flushedOffset}`);
console.log(` displayedText: "${state?.displayedText}"`);
if (state?.pendingText === ' more') {
pass('Pending text is " more"');
} else {
fail('Pending text after more typing', `expected " more", got "${state?.pendingText}"`);
}
const expectedFullDisplay = 'hello world more';
if (state?.displayedText?.includes('hello world') && state?.displayedText?.includes('more')) {
pass('Full displayed text includes flushed + all new text');
} else {
fail('Full display', `expected "${expectedFullDisplay}", got "${state?.displayedText}"`);
}
// ── STEP 7: Backspace through new text into flushed text ──
console.log('\n--- Step 7: Backspace through new and into flushed text ---');
// Delete " more" (5 chars) + 3 from flushed
for (let i = 0; i < 8; i++) {
await page.keyboard.press('Backspace');
await page.waitForTimeout(50);
}
await page.waitForTimeout(300);
state = await getOverlayState(page);
await screenshot(page, '07-after-backspace-into-flushed');
console.log(` pendingText: "${state?.pendingText}"`);
console.log(` flushedOffset: ${state?.flushedOffset}`);
console.log(` flushedText: "${state?.flushedText}"`);
console.log(` displayedText: "${state?.displayedText}"`);
// Should have deleted 5 pending + 3 flushed = flushedOffset 8
if (state?.pendingText === '') {
pass('Pending text empty after backspace');
} else {
fail('Pending text after backspace', `expected "", got "${state?.pendingText}"`);
}
if (state?.flushedOffset === 8) {
pass('Flushed offset correctly decremented (11 - 3 = 8)');
} else {
fail('Flushed offset after backspace', `expected 8, got ${state?.flushedOffset}`);
}
// ── STEP 8: Submit with Enter ──
console.log('\n--- Step 8: Press Enter to submit ---');
await page.keyboard.press('Enter');
await page.waitForTimeout(1000);
state = await getOverlayState(page);
await screenshot(page, '08-after-enter');
console.log(` pendingText: "${state?.pendingText}"`);
console.log(` flushedOffset: ${state?.flushedOffset}`);
console.log(` overlayVisible: ${state?.overlayVisible}`);
console.log(` flushedOffsetsMap: ${JSON.stringify(state?.flushedOffsetsMap)}`);
if (!state?.overlayVisible && state?.pendingText === '' && state?.flushedOffset === 0) {
pass('Overlay fully cleared after Enter');
} else {
fail('State after Enter', `visible=${state?.overlayVisible}, pending="${state?.pendingText}", flushed=${state?.flushedOffset}`);
}
if (!state?.flushedOffsetsMap?.[sessionA]) {
pass('Flushed map cleared for session A after Enter');
} else {
fail('Flushed map after Enter', `still has A: ${state?.flushedOffsetsMap?.[sessionA]}`);
}
// ── STEP 9: Repeat the cycle — ensure it works multiple times ──
console.log('\n--- Step 9: Second cycle — type, switch, switch back, type ---');
await page.keyboard.type('second-round', { delay: 40 });
await page.waitForTimeout(300);
// Switch to B
await clickTab(page, sessionB);
await page.waitForTimeout(2000);
// Switch back to A
await clickTab(page, sessionA);
await page.waitForTimeout(3000);
await focusTerminal(page);
state = await getOverlayState(page);
console.log(` After 2nd cycle — flushedOffset: ${state?.flushedOffset}, flushedText: "${state?.flushedText}"`);
// Type space + more text
await page.keyboard.type(' again', { delay: 40 });
await page.waitForTimeout(500);
state = await getOverlayState(page);
await screenshot(page, '09-second-cycle');
console.log(` pendingText: "${state?.pendingText}"`);
console.log(` flushedOffset: ${state?.flushedOffset}`);
console.log(` displayedText: "${state?.displayedText}"`);
if (state?.displayedText?.includes('second-round') && state?.displayedText?.includes('again')) {
pass('Second cycle: flushed + new text displayed correctly');
} else {
fail('Second cycle display', `expected "second-round again", got "${state?.displayedText}"`);
}
// ── SUMMARY ──
console.log('\n=== SUMMARY ===');
const passes = results.filter(r => r.ok).length;
const fails = results.filter(r => !r.ok).length;
console.log(` ${passes} passed, ${fails} failed`);
console.log(` Screenshots: ${SCREENSHOT_DIR}/`);
if (fails > 0) {
console.log('\n FAILURES:');
for (const r of results.filter(r => !r.ok)) {
console.log(` ✗ ${r.name}: ${r.detail}`);
}
}
if (jsErrors.length > 0) {
console.log(`\n JS errors (${jsErrors.length}):`);
for (const e of jsErrors.slice(0, 10)) console.log(` ${e}`);
}
console.log(`\n Exit code: ${fails > 0 ? 1 : 0}`);
process.exitCode = fails > 0 ? 1 : 0;
} finally {
await browser.close();
console.log('\nCleaning up sessions...');
await deleteSession(sessionA);
await deleteSession(sessionB);
}
}
main().catch(err => {
console.error('Test failed:', err);
process.exit(1);
});
File diff suppressed because it is too large Load Diff
-67
View File
@@ -1,67 +0,0 @@
import { chromium } from 'playwright';
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 1400, height: 900 } });
await page.goto('http://localhost:3099', { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(6000);
await page.locator('.session-tab').first().click();
await page.waitForTimeout(4000);
// Check ALL background colors in the xterm rendering stack
const bgInfo = await page.evaluate(() => {
const term = window.app?.terminal;
if (!term) return { error: 'no terminal' };
const el = term.element;
if (!el) return { error: 'no element' };
const results = {};
// Walk up from xterm-screen to find who provides the background
const screen = el.querySelector('.xterm-screen');
const viewport = el.querySelector('.xterm-viewport');
const rows = el.querySelector('.xterm-rows');
const elements = {
'.xterm (term.element)': el,
'.xterm-viewport': viewport,
'.xterm-screen': screen,
'.xterm-rows': rows,
};
// Also check parent elements
let parent = el.parentElement;
let depth = 0;
while (parent && depth < 5) {
elements[`parent-${depth} (${parent.tagName}.${parent.className?.split(' ')[0] || ''})`] = parent;
parent = parent.parentElement;
depth++;
}
for (const [name, elem] of Object.entries(elements)) {
if (!elem) { results[name] = 'not found'; continue; }
const cs = getComputedStyle(elem);
results[name] = {
background: cs.background?.slice(0, 80),
backgroundColor: cs.backgroundColor,
inlineStyle: elem.style.backgroundColor || elem.style.background || '(none)',
};
}
// Also check if xterm-viewport has inline style set by xterm.js
if (viewport) {
results['viewport-inline-full'] = viewport.style.cssText?.slice(0, 200);
}
// Theme config
results.theme = term.options?.theme;
// Overlay bg
results.overlayBg = window.app?._localEchoOverlay?._font?.backgroundColor;
return results;
});
console.log(JSON.stringify(bgInfo, null, 2));
await browser.close();
-85
View File
@@ -1,85 +0,0 @@
import { chromium } from 'playwright';
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 1400, height: 900 } });
await page.goto('http://localhost:3099', { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(6000);
await page.locator('.session-tab').first().click();
await page.waitForTimeout(4000);
// Sample the ACTUAL canvas pixel color at an empty area
const colorInfo = await page.evaluate(() => {
const term = window.app?.terminal;
if (!term) return { error: 'no terminal' };
// Find the WebGL canvas
const screen = term.element?.querySelector('.xterm-screen');
const canvases = screen?.querySelectorAll('canvas');
const results = {};
for (const canvas of canvases || []) {
const ctx = canvas.getContext('2d') || canvas.getContext('webgl') || canvas.getContext('webgl2');
const ctxType = ctx?.constructor?.name;
if (ctx && (ctxType === 'CanvasRenderingContext2D')) {
// 2D context — can read pixels directly
try {
const pixel = ctx.getImageData(10, 10, 1, 1).data;
results['2d'] = {
r: pixel[0], g: pixel[1], b: pixel[2], a: pixel[3],
hex: '#' + [pixel[0], pixel[1], pixel[2]].map(v => v.toString(16).padStart(2, '0')).join(''),
};
} catch (e) {
results['2d'] = { error: e.message };
}
} else if (ctx) {
// WebGL context — use readPixels
try {
const gl = ctx;
const pixel = new Uint8Array(4);
// Read from an empty area (bottom-left corner, row 0 col 0)
gl.readPixels(10, canvas.height - 10, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, pixel);
results['webgl'] = {
r: pixel[0], g: pixel[1], b: pixel[2], a: pixel[3],
hex: '#' + [pixel[0], pixel[1], pixel[2]].map(v => v.toString(16).padStart(2, '0')).join(''),
};
} catch (e) {
results['webgl'] = { error: e.message };
}
}
}
// Also check what the terminal theme says
results.theme = {
background: term.options?.theme?.background,
foreground: term.options?.theme?.foreground,
};
// Check the overlay's configured background
const overlay = window.app?._localEchoOverlay;
results.overlayBg = overlay?._font?.backgroundColor;
// Check xterm-rows computed style
const rows = term.element?.querySelector('.xterm-rows');
if (rows) {
results.rowsBg = getComputedStyle(rows).backgroundColor;
}
// Check .xterm element bg
const xtermEl = term.element;
if (xtermEl) {
results.xtermBg = getComputedStyle(xtermEl).backgroundColor;
}
// Canvas count and types
results.canvasCount = canvases?.length;
results.canvasClasses = Array.from(canvases || []).map(c => c.className);
return results;
});
console.log(JSON.stringify(colorInfo, null, 2));
await browser.close();
console.log('Done');
-48
View File
@@ -1,48 +0,0 @@
import { chromium } from 'playwright';
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 1400, height: 900 } });
await page.goto('http://localhost:3099', { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(6000);
const firstTab = page.locator('.session-tab').first();
await firstTab.click();
await page.waitForTimeout(5000);
const info = await page.evaluate(() => {
const term = window.app?.terminal;
if (!term) return { error: 'no app.terminal' };
const core = term._core;
const renderService = core?._renderService;
const dims = renderService?.dimensions;
if (!dims) return { error: 'no dimensions', hasCore: !!core, hasRenderService: !!renderService };
return {
css: {
cell: dims.css?.cell,
char: dims.css?.char,
canvas: dims.css?.canvas,
},
device: {
cell: dims.device?.cell,
char: dims.device?.char,
canvas: dims.device?.canvas,
},
topLevelKeys: Object.keys(dims),
cssKeys: dims.css ? Object.keys(dims.css) : [],
deviceKeys: dims.device ? Object.keys(dims.device) : [],
cssAllDetail: dims.css ? Object.fromEntries(
Object.entries(dims.css).map(([k, v]) => [k, v && typeof v === 'object' ? { ...v } : v])
) : null,
deviceAllDetail: dims.device ? Object.fromEntries(
Object.entries(dims.device).map(([k, v]) => [k, v && typeof v === 'object' ? { ...v } : v])
) : null,
dpr: window.devicePixelRatio,
};
});
console.log(JSON.stringify(info, null, 2));
await browser.close();
-87
View File
@@ -1,87 +0,0 @@
import { chromium } from 'playwright';
const browser = await chromium.launch({ headless: true, deviceScaleFactor: 2 });
const page = await browser.newPage({ viewport: { width: 1400, height: 900 } });
await page.goto('http://localhost:3099', { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(6000);
await page.locator('.session-tab').first().click();
await page.waitForTimeout(4000);
await page.evaluate(() => {
const settings = JSON.parse(localStorage.getItem('codeman-settings') || '{}');
settings.localEchoEnabled = true;
localStorage.setItem('codeman-settings', JSON.stringify(settings));
if (window.app?.updateLocalEchoState) window.app.updateLocalEchoState();
});
await page.waitForTimeout(500);
// Force render and capture zoomed area of just the overlay
const result = await page.evaluate(() => {
const overlay = window.app?._localEchoOverlay;
if (!overlay || !overlay._overlay) return { error: 'no overlay' };
overlay._lastPromptPos = { row: 5, col: 0 };
overlay._pendingText = 'h';
overlay._lastRenderKey = '';
overlay._render();
const container = overlay._overlay;
const lineDiv = container.querySelector('div');
const charSpan = container.querySelector('div > span');
// Get precise rects
const lineDivRect = lineDiv?.getBoundingClientRect();
const charSpanRect = charSpan?.getBoundingClientRect();
const screenRect = document.querySelector('.xterm-screen')?.getBoundingClientRect();
// Check what's right below the line div
const belowY = (lineDivRect?.bottom || 0) + 1;
const belowX = lineDivRect?.left || 0;
const elBelow = document.elementFromPoint(belowX, belowY);
return {
dpr: window.devicePixelRatio,
lineDiv: {
rect: lineDivRect,
bg: lineDiv?.style.backgroundColor,
height: lineDiv?.style.height,
},
charSpan: {
rect: charSpanRect,
transform: charSpan?.style.transform,
// Check if span bottom exceeds lineDiv bottom
extendsBelow: charSpanRect && lineDivRect ?
(charSpanRect.bottom - lineDivRect.bottom).toFixed(2) : 'N/A',
},
elBelow: elBelow ? {
tag: elBelow.tagName,
class: elBelow.className,
} : null,
// Check the exact background color of the canvas
canvasBg: document.querySelector('.xterm-screen')?.style.backgroundColor,
themeBg: window.app?.terminal?.options?.theme?.background,
overlayBg: lineDiv?.style.backgroundColor,
};
});
console.log(JSON.stringify(result, null, 2));
// Take tight screenshot around the first char area
const screenEl = page.locator('.xterm-screen').first();
const screenBox = await screenEl.boundingBox();
if (screenBox) {
// Crop to just the overlay area (row 5, first few columns)
const cellH = 19;
const promptRow = 5;
const cropY = screenBox.y + (promptRow - 1) * cellH;
const cropH = cellH * 3;
await page.screenshot({
path: '/tmp/first-char-zoomed.png',
clip: { x: screenBox.x, y: cropY, width: 200, height: cropH },
});
console.log('Zoomed screenshot saved');
}
await page.evaluate(() => { window.app?._localEchoOverlay?.clear(); });
await browser.close();
console.log('Done');
-116
View File
@@ -1,116 +0,0 @@
import { chromium } from 'playwright';
const SESSION_ID = '9270f4e4-84f7-414c-b901-837f12c30e59';
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 1400, height: 900 } });
page.on('console', msg => {
if (msg.type() === 'error') console.log('CONSOLE ERROR:', msg.text());
});
await page.goto('http://localhost:3099', { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(4000);
// Click on test session tab
const tab = page.locator(`.session-tab[data-id="${SESSION_ID}"]`);
if (await tab.isVisible({ timeout: 3000 }).catch(() => false)) {
await tab.click();
await page.waitForTimeout(2000);
} else {
console.log('Tab not found, using whatever is active');
}
await page.waitForTimeout(2000);
// Ensure local echo is OFF
const localEchoToggle = page.locator('#localEchoToggle');
const isChecked = await localEchoToggle.isChecked().catch(() => false);
console.log('Local echo initially:', isChecked);
if (isChecked) {
await localEchoToggle.click();
await page.waitForTimeout(500);
}
// Type with echo OFF
await page.locator('.xterm-helper-textarea').first().focus();
await page.waitForTimeout(300);
await page.keyboard.type('hello test', { delay: 80 });
await page.waitForTimeout(1000);
const termScreen = page.locator('.xterm-screen').first();
await termScreen.screenshot({ path: '/tmp/echo-off-term.png' });
console.log('Saved /tmp/echo-off-term.png');
// Clear
for (let i = 0; i < 10; i++) await page.keyboard.press('Backspace');
await page.waitForTimeout(500);
// Enable local echo
await localEchoToggle.click();
await page.waitForTimeout(500);
console.log('Local echo now:', await localEchoToggle.isChecked().catch(() => false));
// Type with echo ON
await page.locator('.xterm-helper-textarea').first().focus();
await page.waitForTimeout(300);
await page.keyboard.type('hello test', { delay: 80 });
await page.waitForTimeout(1500);
await termScreen.screenshot({ path: '/tmp/echo-on-term.png' });
console.log('Saved /tmp/echo-on-term.png');
await page.screenshot({ path: '/tmp/echo-on-full.png' });
// Inspect overlay DOM
const overlayInfo = await page.evaluate(() => {
const overlay = document.querySelector('.zl-overlay');
if (!overlay) return { found: false };
const firstDiv = overlay.querySelector('div');
const spans = overlay.querySelectorAll('span');
const firstCharSpan = Array.from(spans).find(s => s.textContent && s.textContent.trim());
return {
found: true,
display: overlay.style.display,
top: overlay.style.top,
containerOverflow: overlay.style.overflow,
containerHeight: overlay.style.height,
firstDiv: firstDiv ? {
height: firstDiv.style.height,
top: firstDiv.style.top,
overflow: firstDiv.style.overflow,
backgroundColor: firstDiv.style.backgroundColor,
} : null,
firstCharSpan: firstCharSpan ? {
text: firstCharSpan.textContent,
transform: firstCharSpan.style.transform,
top: firstCharSpan.style.top,
height: firstCharSpan.style.height,
lineHeight: firstCharSpan.style.lineHeight,
width: firstCharSpan.style.width,
} : null,
spanCount: spans.length,
};
});
console.log('\nOverlay info:', JSON.stringify(overlayInfo, null, 2));
// Cell dimensions
const cellInfo = await page.evaluate(() => {
if (window.app?.sessions) {
for (const [, s] of Object.entries(window.app.sessions)) {
const dims = s.terminal?._core?._renderService?.dimensions;
if (dims) return { css: dims.css?.cell, device_char: dims.device?.char };
}
}
return { error: 'not found' };
});
console.log('Cell dims:', JSON.stringify(cellInfo, null, 2));
// Cleanup
await page.evaluate(async (id) => {
await fetch('/api/sessions/' + id, { method: 'DELETE' });
}, SESSION_ID);
await browser.close();
console.log('\nDone');
-114
View File
@@ -1,114 +0,0 @@
import { chromium } from 'playwright';
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 1400, height: 900 } });
page.on('console', msg => console.log('CONSOLE:', msg.type(), msg.text()));
page.on('pageerror', err => console.log('PAGE ERROR:', err.message));
await page.goto('http://localhost:3099', { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(4000);
// Create a fresh session
const resp = await page.evaluate(async () => {
const r = await fetch('/api/sessions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'ov-test', mode: 'claude' })
});
return r.json();
});
const sid = resp.session?.id;
console.log('Session:', sid?.slice(0, 8));
await page.waitForTimeout(3000);
// Click on it
await page.locator(`.session-tab[data-id="${sid}"]`).click();
await page.waitForTimeout(3000);
// Check for the local echo toggle
const toggle = page.locator('#localEchoToggle');
const toggleVisible = await toggle.isVisible().catch(() => false);
console.log('Toggle visible:', toggleVisible);
if (!toggleVisible) {
// Maybe need to open settings or it's somewhere else
console.log('Looking for local echo toggle...');
const all = await page.locator('input[type="checkbox"]').all();
for (const cb of all) {
const id = await cb.getAttribute('id');
const label = await cb.evaluate(el => el.parentElement?.textContent?.trim().slice(0, 30));
console.log(' checkbox:', id, label);
}
}
// Get cell dimensions first
const cellInfo = await page.evaluate(() => {
if (window.app?.sessions) {
for (const [id, s] of Object.entries(window.app.sessions)) {
const dims = s.terminal?._core?._renderService?.dimensions;
if (dims) return {
id: id.slice(0, 8),
cssCell: dims.css?.cell,
deviceChar: dims.device?.char,
actualFull: dims,
};
}
}
return { error: 'no terminal' };
});
console.log('Cell info:', JSON.stringify(cellInfo, null, 2));
// Focus terminal and type with echo OFF
const textarea = page.locator('.xterm-helper-textarea').first();
await textarea.focus();
await page.waitForTimeout(500);
await page.keyboard.type('hello test', { delay: 80 });
await page.waitForTimeout(1500);
await page.screenshot({ path: '/tmp/echo-off-full.png' });
console.log('Saved echo-off-full.png');
// Clear
for (let i = 0; i < 10; i++) await page.keyboard.press('Backspace');
await page.waitForTimeout(500);
// Enable local echo
if (toggleVisible) {
await toggle.click();
await page.waitForTimeout(500);
console.log('Toggled local echo ON');
}
// Type again
await textarea.focus();
await page.waitForTimeout(300);
await page.keyboard.type('hello test', { delay: 80 });
await page.waitForTimeout(1500);
await page.screenshot({ path: '/tmp/echo-on-full.png' });
console.log('Saved echo-on-full.png');
// Get overlay info
const oi = await page.evaluate(() => {
const o = document.querySelector('.zl-overlay');
if (!o) return { found: false, html: 'none' };
return {
found: true,
display: o.style.display,
top: o.style.top,
childCount: o.children.length,
innerHTML: o.innerHTML.slice(0, 500),
};
});
console.log('Overlay:', JSON.stringify(oi, null, 2));
// Cleanup
if (sid) {
await page.evaluate(async (id) => {
await fetch('/api/sessions/' + id, { method: 'DELETE' });
}, sid);
}
await browser.close();
console.log('Done');
-109
View File
@@ -1,109 +0,0 @@
import { chromium } from 'playwright';
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 1400, height: 900 } });
await page.goto('http://localhost:3099', { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(6000);
// Click first tab
await page.locator('.session-tab').first().click();
await page.waitForTimeout(4000);
// Get terminal and enable local echo via JS
const result = await page.evaluate(() => {
const term = window.app.terminal;
if (!term) return { error: 'no terminal' };
const dims = term._core._renderService.dimensions;
// Toggle local echo on via settings
const checkbox = document.getElementById('appSettingsLocalEcho');
return {
cellW: dims.css.cell.width,
cellH: dims.css.cell.height,
charTop: dims.device?.char?.top ?? 0,
charHeight: dims.device?.char?.height ?? 0,
dpr: window.devicePixelRatio,
fontSize: term.options.fontSize,
fontFamily: term.options.fontFamily,
localEchoCheckbox: !!checkbox,
localEchoChecked: checkbox?.checked,
};
});
console.log('Terminal info:', JSON.stringify(result, null, 2));
// Enable local echo via the app settings checkbox
await page.evaluate(() => {
const cb = document.getElementById('appSettingsLocalEcho');
if (cb && !cb.checked) {
cb.click();
cb.dispatchEvent(new Event('change', { bubbles: true }));
}
});
await page.waitForTimeout(500);
// Focus terminal and type
await page.locator('.xterm-helper-textarea').first().focus();
await page.waitForTimeout(300);
await page.keyboard.type('hello', { delay: 100 });
await page.waitForTimeout(1500);
// Take screenshots and analyze pixel positions
const pixelInfo = await page.evaluate(() => {
const overlay = document.querySelector('.zl-overlay');
if (!overlay) return { error: 'no overlay', overlayDisplay: overlay?.style?.display };
const spans = overlay.querySelectorAll('span');
const firstCharSpan = Array.from(spans).find(s => s.textContent === 'h');
if (!firstCharSpan) {
return { error: 'no h span', spanCount: spans.length, innerHTML: overlay.innerHTML.slice(0, 200) };
}
const spanRect = firstCharSpan.getBoundingClientRect();
const containerRect = overlay.getBoundingClientRect();
const lineDiv = firstCharSpan.parentElement;
const lineDivRect = lineDiv.getBoundingClientRect();
// Get the canvas position for comparison
const canvasEl = document.querySelector('.xterm-screen canvas');
const canvasRect = canvasEl?.getBoundingClientRect();
return {
span: {
top: spanRect.top,
bottom: spanRect.bottom,
height: spanRect.height,
relativeTop: spanRect.top - lineDivRect.top,
},
lineDiv: {
top: lineDivRect.top,
bottom: lineDivRect.bottom,
height: lineDivRect.height,
},
container: {
top: containerRect.top,
height: containerRect.height,
},
canvas: canvasRect ? {
top: canvasRect.top,
} : null,
spanStyle: {
transform: firstCharSpan.style.transform,
top: firstCharSpan.style.top,
height: firstCharSpan.style.height,
lineHeight: firstCharSpan.style.lineHeight,
},
};
});
console.log('Pixel info:', JSON.stringify(pixelInfo, null, 2));
// Screenshot the terminal area
const termScreen = page.locator('.xterm-screen').first();
await termScreen.screenshot({ path: '/tmp/overlay-test-zoomed.png' });
console.log('Screenshot saved to /tmp/overlay-test-zoomed.png');
await browser.close();
console.log('Done');
-69
View File
@@ -1,69 +0,0 @@
import { chromium } from 'playwright';
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 1400, height: 900 } });
await page.goto('http://localhost:3099', { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(6000);
await page.locator('.session-tab').first().click();
await page.waitForTimeout(4000);
// Enable local echo
await page.evaluate(() => {
const s = JSON.parse(localStorage.getItem('codeman-settings') || '{}');
s.localEchoEnabled = true;
localStorage.setItem('codeman-settings', JSON.stringify(s));
window.app?.updateLocalEchoState?.();
});
await page.waitForTimeout(500);
// Force render overlay with single char at a realistic prompt position
await page.evaluate(() => {
const ov = window.app?._localEchoOverlay;
if (!ov) return;
// Find actual bottom row for a realistic position
const buf = ov._terminal?.buffer?.active;
const row = buf ? Math.max(0, buf.baseY + ov._terminal.rows - 2) - buf.viewportY : 5;
ov._lastPromptPos = { row, col: 0 };
ov._pendingText = 'hello test';
ov._lastRenderKey = '';
ov._render();
});
await page.waitForTimeout(500);
// Take full-page and zoomed screenshots
await page.screenshot({ path: '/tmp/sc-full.png' });
// Zoom into overlay area — find where the overlay actually is
const overlayRect = await page.evaluate(() => {
const ov = window.app?._localEchoOverlay?._overlay;
if (!ov) return null;
const r = ov.getBoundingClientRect();
const lineDiv = ov.querySelector('div');
const lr = lineDiv?.getBoundingClientRect();
return {
container: { x: r.x, y: r.y, w: r.width, h: r.height },
lineDiv: lr ? { x: lr.x, y: lr.y, w: lr.width, h: lr.height } : null,
};
});
console.log('Overlay rect:', JSON.stringify(overlayRect));
if (overlayRect?.lineDiv) {
const ld = overlayRect.lineDiv;
// Capture area around the overlay: 1 row above, the overlay, 1 row below
const margin = 20; // ~1 cell height
await page.screenshot({
path: '/tmp/sc-overlay-zoomed.png',
clip: {
x: Math.max(0, ld.x - 5),
y: Math.max(0, ld.y - margin),
width: Math.min(400, ld.w),
height: ld.h + margin * 2 + 5,
},
});
console.log('Zoomed screenshot saved');
}
await page.evaluate(() => { window.app?._localEchoOverlay?.clear(); });
await browser.close();
console.log('Done');
-69
View File
@@ -1,69 +0,0 @@
import { chromium } from 'playwright';
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 1400, height: 900 } });
await page.goto('http://localhost:3099', { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(6000);
await page.locator('.session-tab').first().click();
await page.waitForTimeout(4000);
// Enable local echo and update state
await page.evaluate(() => {
const settings = JSON.parse(localStorage.getItem('codeman-settings') || '{}');
settings.localEchoEnabled = true;
localStorage.setItem('codeman-settings', JSON.stringify(settings));
if (window.app?.updateLocalEchoState) window.app.updateLocalEchoState();
});
await page.waitForTimeout(500);
// Force render with fake prompt position and inspect
const result = await page.evaluate(() => {
const overlay = window.app?._localEchoOverlay;
if (!overlay || !overlay._overlay) return { error: 'no overlay' };
const term = overlay._terminal;
const dims = term._core._renderService.dimensions;
const cellH = dims.css.cell.height;
const charTop = dims.device?.char?.top ?? 0;
const charHeight = dims.device?.char?.height ?? cellH;
// Calculate the correction
const cssCenter = (cellH - charHeight) / 2;
const correction = charTop - cssCenter;
// Force render
overlay._lastPromptPos = { row: 5, col: 0 };
overlay._pendingText = 'hello test';
overlay._lastRenderKey = '';
overlay._render();
const spans = overlay._overlay.querySelectorAll('span');
const firstChar = Array.from(spans).find(s => s.textContent === 'h');
return {
cellH, charTop, charHeight,
cssCenter,
correction: correction.toFixed(2),
firstChar: firstChar ? {
top: firstChar.style.top,
height: firstChar.style.height,
lineHeight: firstChar.style.lineHeight,
transform: firstChar.style.transform,
} : null,
};
});
console.log('Fix result:', JSON.stringify(result, null, 2));
// Take screenshot
await page.locator('.xterm-screen').first().screenshot({ path: '/tmp/fix-verify-term.png' });
console.log('Screenshot saved');
// Clean up
await page.evaluate(() => {
window.app?._localEchoOverlay?.clear();
});
await browser.close();
console.log('Done');
-61
View File
@@ -1,61 +0,0 @@
import { chromium } from 'playwright';
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 1400, height: 900 } });
await page.goto('http://localhost:3099', { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(6000);
await page.locator('.session-tab').first().click();
await page.waitForTimeout(4000);
await page.evaluate(() => {
const settings = JSON.parse(localStorage.getItem('codeman-settings') || '{}');
settings.localEchoEnabled = true;
localStorage.setItem('codeman-settings', JSON.stringify(settings));
if (window.app?.updateLocalEchoState) window.app.updateLocalEchoState();
});
const result = await page.evaluate(() => {
const overlay = window.app?._localEchoOverlay;
if (!overlay) return { error: 'no overlay' };
overlay._lastPromptPos = { row: 5, col: 0 };
overlay._pendingText = 'hello test';
overlay._lastRenderKey = '';
overlay._render();
const container = overlay._overlay;
const lineDiv = container.querySelector('div');
const charSpan = container.querySelector('div > span');
const lineDivRect = lineDiv?.getBoundingClientRect();
const charSpanRect = charSpan?.getBoundingClientRect();
return {
lineDiv: { bottom: lineDivRect?.bottom, height: lineDivRect?.height },
charSpan: {
bottom: charSpanRect?.bottom,
height: charSpanRect?.height,
transform: charSpan?.style.transform,
top: charSpan?.style.top,
lineHeight: charSpan?.style.lineHeight,
},
extendsBelow: charSpanRect && lineDivRect ?
(charSpanRect.bottom - lineDivRect.bottom).toFixed(4) : 'N/A',
};
});
console.log(JSON.stringify(result, null, 2));
// Screenshot
const screenEl = page.locator('.xterm-screen').first();
const screenBox = await screenEl.boundingBox();
if (screenBox) {
await page.screenshot({
path: '/tmp/no-overhang-zoomed.png',
clip: { x: screenBox.x, y: screenBox.y + 4 * 19, width: 200, height: 57 },
});
}
await page.evaluate(() => { window.app?._localEchoOverlay?.clear(); });
await browser.close();
console.log('Done');

Some files were not shown because too many files have changed in this diff Show More