jeo-code 0.8.21 → 0.8.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
6
6
 
7
7
  The README mirrors the latest 5 entries — regenerate with `bun run changelog:sync`.
8
8
 
9
+ ## [0.8.22] - 2026-07-11
10
+ _A rescan of the working tree turned up a resume-fidelity gap plus two unwired TUI safety-net helpers left by a concurrent session; each was traced, wired where needed, and verified before shipping._
11
+
12
+ ### Added
13
+ - **Full-transcript restore on `/resume`/`--continue`** (`src/commands/launch.ts`) — all three non-interactive startup resume paths (`--resume <id>`, `--continue`/`-c`, and the TTY-less bare `--resume`) previously printed only `"Resumed session <id> (N messages)."` with no reproduction of the prior conversation. They now render the same `formatTranscript` ledger (user prompt boxes, ✔/✗ tool-call lines, `jeo ◂` reply blocks) that the mid-session `/session resume` and `/history` commands already used, so resuming any session created going forward reproduces the prior work on screen instead of a one-line summary. Live-verified against a real on-disk session and a real `bun run src/cli.ts launch --resume <id>` child process.
14
+ - **Resize-detection safety net** (`src/tui/terminal.ts`'s `watchResize`/`liveWindowSize`, wired into `src/tui/app.ts`'s `LaunchTui`) — a 300ms poll against the real `TIOCGWINSZ` ioctl (`getWindowSize()`) that self-heals `process.stdout.columns`/`.rows` and repaints when a `'resize'` (SIGWINCH) event is missed — e.g. a tmux pane switch while jeo's pane isn't foregrounded, or a SIGCONT race after Ctrl-Z. Composes safely alongside the existing `'resize'` listener; started in `begin()`, torn down in `finish()`.
15
+
16
+ ### Fixed
17
+ - **`updateSessionDraft` trim bug** (`src/agent/session.ts`) — the no-op comparison checked `draft.trim()` for truthiness but persisted the untrimmed original string; now trims once and persists the trimmed value consistently.
18
+
19
+ ### Verified
20
+ - Full `bun test` — 2906 pass / 0 fail across 291 files.
21
+ - `bun run typecheck` — no errors.
22
+ - Live PTY/CLI verification of the resume-transcript restore end-to-end (real session file, real child-process `launch --resume`).
23
+
24
+
9
25
  ## [0.8.21] - 2026-07-10
10
26
  _"반영할꺼 같은방식으로 체크하고 배포까지" — a rescan of the working tree (after the prior audit pass) turned up 15 uncommitted files: 3 genuinely distinct features left unwired by a concurrent session, partially casualty of an earlier accidental `git checkout` incident this session (disclosed in the 0.8.20 entry above). Each was independently traced from its existing plumbing, wired to a real call site, tested, and one live-verified end-to-end over a real PTY before shipping._
11
27
 
package/README.ja.md CHANGED
@@ -265,11 +265,11 @@ CI は `.github/workflows/npm-publish.yml` で公開します — GitHub リリ
265
265
  ## 変更履歴 (Changelog)
266
266
 
267
267
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
268
+ - **[0.8.22]** (2026-07-11) — A rescan of the working tree turned up a resume-fidelity gap plus two unwired TUI safety-net helpers left by a concurrent session; each was traced, wired where needed, and verified before shipping.
268
269
  - **[0.8.21]** (2026-07-10) — "반영할꺼 같은방식으로 체크하고 배포까지" — a rescan of the working tree (after the prior audit pass) turned up 15 uncommitted files: 3 genuinely distinct features left unwired by a concurrent session, partially casualty of an earlier accidental `git checkout` incident this session (disclosed in the 0.8.20 entry above). Each was independently traced from its existing plumbing, wired to a real call site, tested, and one live-verified end-to-end over a real PTY before shipping.
269
270
  - **[0.8.20]** (2026-07-10) — "모든 검증 다시 리뷰하고 변경사항 모두 체크해" — 4 fresh, skeptical subagents independently re-audited every change from v0.8.17-0.8.19 (bc8768f..92c6b7d) with zero trust in prior claims: a code-correctness auditor manually traced the Antigravity routing logic against the live catalog, a test-integrity auditor mutation-tested the new tests (confirmed each one genuinely fails when its fix is reverted) and ran the suite fresh, a live-behavior verifier re-reproduced all 4 behavioral claims from scratch with self-generated data (own session ids, own mock server, own HTTP status code), and a docs-accuracy auditor cross-checked every README/CHANGELOG claim against current code in isolated git worktrees. Verdict: all core logic and tests GENUINE/CONFIRMED CORRECT — but the audit surfaced 1 real doc staleness gap, 1 changelog count error, and 2 minor precision gaps, all fixed here.
270
271
  - **[0.8.19]** (2026-07-09) — "프롬프트 라우팅에 안티그라비티 프로바이더의 경우, 안티그라비티용 소넷과 오퍼스도 3.5급으로 라우팅될수있도록해줘" — Antigravity re-exports 3 distinct model families (Anthropic Claude, Google Gemini, OpenAI GPT-OSS) behind one credential, structurally unlike every other provider. `high`/`complex` tier auto-select always resolved to Google's Gemini 3.5 rows: Anthropic's real 64,000-token output ceiling lost a same-thinking-tier tie to Google's 65,536 by a margin with no practical significance, and Gemini's 1M-token context further outranked Claude's real 200K window — so `antigravity/claude-sonnet-4-6`/`antigravity/claude-opus-4-6-thinking` were NEVER reachable through auto-select, even though both were already correctly `sizeClass`-tagged into the `high`/`complex` pools.
271
272
  - **[0.8.18]** (2026-07-09) — "라우팅 매 프롬프트마다 변경되는지, route why 의도대로 동작하는지 검증하고 배포할게있는지 확인해줘" — 2 parallel subagents live-verified per-prompt routing + `/route why` (including the previously-only-unit-tested post-call equivalent-model fallback path, now reproduced against a real mock 500-error server) and audited a disconnected `RouteHistory` class shipped as incomplete scaffolding in 0.8.16/0.8.17 (class + 11 tests existed, but nothing ever called it — no `/route history` subcommand, no wiring into the turn loop). Live testing also surfaced one real bug: the TUI footer's model/provider label never updated after a mid-turn fallback, staying on the pre-fallback model for the rest of that turn's render even though the backend decision (`lastRouteDecision`) was already correct.
272
- - **[0.8.17]** (2026-07-09) — "서브에이전트 이용해. 병렬루 부가 나머지모두 개선하고 검증후 배포까지하자" — a prior 4-way parallel subagent cross-verification pass found the README `[!CAUTION]` routing-lock block (added this session for "프롬프트 라우팅이 한번 정해지면 안바뀌는데?") was directionally correct but had 2 real inaccuracies, plus a concrete test-coverage gap: every existing pin/override regression test drove the lock via the `--model` CLI startup flag, never via typing `/model <name>` as an interactive slash command mid-session, and none proved persistence across 3+ consecutive turns.
273
273
 
274
274
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
275
275
  <!-- CHANGELOG:END -->
package/README.ko.md CHANGED
@@ -266,11 +266,11 @@ CI는 `.github/workflows/npm-publish.yml`로 배포합니다 — GitHub 릴리
266
266
  ## 변경 이력 (Changelog)
267
267
 
268
268
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
269
+ - **[0.8.22]** (2026-07-11) — A rescan of the working tree turned up a resume-fidelity gap plus two unwired TUI safety-net helpers left by a concurrent session; each was traced, wired where needed, and verified before shipping.
269
270
  - **[0.8.21]** (2026-07-10) — "반영할꺼 같은방식으로 체크하고 배포까지" — a rescan of the working tree (after the prior audit pass) turned up 15 uncommitted files: 3 genuinely distinct features left unwired by a concurrent session, partially casualty of an earlier accidental `git checkout` incident this session (disclosed in the 0.8.20 entry above). Each was independently traced from its existing plumbing, wired to a real call site, tested, and one live-verified end-to-end over a real PTY before shipping.
270
271
  - **[0.8.20]** (2026-07-10) — "모든 검증 다시 리뷰하고 변경사항 모두 체크해" — 4 fresh, skeptical subagents independently re-audited every change from v0.8.17-0.8.19 (bc8768f..92c6b7d) with zero trust in prior claims: a code-correctness auditor manually traced the Antigravity routing logic against the live catalog, a test-integrity auditor mutation-tested the new tests (confirmed each one genuinely fails when its fix is reverted) and ran the suite fresh, a live-behavior verifier re-reproduced all 4 behavioral claims from scratch with self-generated data (own session ids, own mock server, own HTTP status code), and a docs-accuracy auditor cross-checked every README/CHANGELOG claim against current code in isolated git worktrees. Verdict: all core logic and tests GENUINE/CONFIRMED CORRECT — but the audit surfaced 1 real doc staleness gap, 1 changelog count error, and 2 minor precision gaps, all fixed here.
271
272
  - **[0.8.19]** (2026-07-09) — "프롬프트 라우팅에 안티그라비티 프로바이더의 경우, 안티그라비티용 소넷과 오퍼스도 3.5급으로 라우팅될수있도록해줘" — Antigravity re-exports 3 distinct model families (Anthropic Claude, Google Gemini, OpenAI GPT-OSS) behind one credential, structurally unlike every other provider. `high`/`complex` tier auto-select always resolved to Google's Gemini 3.5 rows: Anthropic's real 64,000-token output ceiling lost a same-thinking-tier tie to Google's 65,536 by a margin with no practical significance, and Gemini's 1M-token context further outranked Claude's real 200K window — so `antigravity/claude-sonnet-4-6`/`antigravity/claude-opus-4-6-thinking` were NEVER reachable through auto-select, even though both were already correctly `sizeClass`-tagged into the `high`/`complex` pools.
272
273
  - **[0.8.18]** (2026-07-09) — "라우팅 매 프롬프트마다 변경되는지, route why 의도대로 동작하는지 검증하고 배포할게있는지 확인해줘" — 2 parallel subagents live-verified per-prompt routing + `/route why` (including the previously-only-unit-tested post-call equivalent-model fallback path, now reproduced against a real mock 500-error server) and audited a disconnected `RouteHistory` class shipped as incomplete scaffolding in 0.8.16/0.8.17 (class + 11 tests existed, but nothing ever called it — no `/route history` subcommand, no wiring into the turn loop). Live testing also surfaced one real bug: the TUI footer's model/provider label never updated after a mid-turn fallback, staying on the pre-fallback model for the rest of that turn's render even though the backend decision (`lastRouteDecision`) was already correct.
273
- - **[0.8.17]** (2026-07-09) — "서브에이전트 이용해. 병렬루 부가 나머지모두 개선하고 검증후 배포까지하자" — a prior 4-way parallel subagent cross-verification pass found the README `[!CAUTION]` routing-lock block (added this session for "프롬프트 라우팅이 한번 정해지면 안바뀌는데?") was directionally correct but had 2 real inaccuracies, plus a concrete test-coverage gap: every existing pin/override regression test drove the lock via the `--model` CLI startup flag, never via typing `/model <name>` as an interactive slash command mid-session, and none proved persistence across 3+ consecutive turns.
274
274
 
275
275
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
276
276
  <!-- CHANGELOG:END -->
package/README.md CHANGED
@@ -273,11 +273,11 @@ Huge thanks to [gajae-code](https://github.com/Yeachan-Heo/gajae-code) for the i
273
273
  ## Changelog
274
274
 
275
275
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
276
+ - **[0.8.22]** (2026-07-11) — A rescan of the working tree turned up a resume-fidelity gap plus two unwired TUI safety-net helpers left by a concurrent session; each was traced, wired where needed, and verified before shipping.
276
277
  - **[0.8.21]** (2026-07-10) — "반영할꺼 같은방식으로 체크하고 배포까지" — a rescan of the working tree (after the prior audit pass) turned up 15 uncommitted files: 3 genuinely distinct features left unwired by a concurrent session, partially casualty of an earlier accidental `git checkout` incident this session (disclosed in the 0.8.20 entry above). Each was independently traced from its existing plumbing, wired to a real call site, tested, and one live-verified end-to-end over a real PTY before shipping.
277
278
  - **[0.8.20]** (2026-07-10) — "모든 검증 다시 리뷰하고 변경사항 모두 체크해" — 4 fresh, skeptical subagents independently re-audited every change from v0.8.17-0.8.19 (bc8768f..92c6b7d) with zero trust in prior claims: a code-correctness auditor manually traced the Antigravity routing logic against the live catalog, a test-integrity auditor mutation-tested the new tests (confirmed each one genuinely fails when its fix is reverted) and ran the suite fresh, a live-behavior verifier re-reproduced all 4 behavioral claims from scratch with self-generated data (own session ids, own mock server, own HTTP status code), and a docs-accuracy auditor cross-checked every README/CHANGELOG claim against current code in isolated git worktrees. Verdict: all core logic and tests GENUINE/CONFIRMED CORRECT — but the audit surfaced 1 real doc staleness gap, 1 changelog count error, and 2 minor precision gaps, all fixed here.
278
279
  - **[0.8.19]** (2026-07-09) — "프롬프트 라우팅에 안티그라비티 프로바이더의 경우, 안티그라비티용 소넷과 오퍼스도 3.5급으로 라우팅될수있도록해줘" — Antigravity re-exports 3 distinct model families (Anthropic Claude, Google Gemini, OpenAI GPT-OSS) behind one credential, structurally unlike every other provider. `high`/`complex` tier auto-select always resolved to Google's Gemini 3.5 rows: Anthropic's real 64,000-token output ceiling lost a same-thinking-tier tie to Google's 65,536 by a margin with no practical significance, and Gemini's 1M-token context further outranked Claude's real 200K window — so `antigravity/claude-sonnet-4-6`/`antigravity/claude-opus-4-6-thinking` were NEVER reachable through auto-select, even though both were already correctly `sizeClass`-tagged into the `high`/`complex` pools.
279
280
  - **[0.8.18]** (2026-07-09) — "라우팅 매 프롬프트마다 변경되는지, route why 의도대로 동작하는지 검증하고 배포할게있는지 확인해줘" — 2 parallel subagents live-verified per-prompt routing + `/route why` (including the previously-only-unit-tested post-call equivalent-model fallback path, now reproduced against a real mock 500-error server) and audited a disconnected `RouteHistory` class shipped as incomplete scaffolding in 0.8.16/0.8.17 (class + 11 tests existed, but nothing ever called it — no `/route history` subcommand, no wiring into the turn loop). Live testing also surfaced one real bug: the TUI footer's model/provider label never updated after a mid-turn fallback, staying on the pre-fallback model for the rest of that turn's render even though the backend decision (`lastRouteDecision`) was already correct.
280
- - **[0.8.17]** (2026-07-09) — "서브에이전트 이용해. 병렬루 부가 나머지모두 개선하고 검증후 배포까지하자" — a prior 4-way parallel subagent cross-verification pass found the README `[!CAUTION]` routing-lock block (added this session for "프롬프트 라우팅이 한번 정해지면 안바뀌는데?") was directionally correct but had 2 real inaccuracies, plus a concrete test-coverage gap: every existing pin/override regression test drove the lock via the `--model` CLI startup flag, never via typing `/model <name>` as an interactive slash command mid-session, and none proved persistence across 3+ consecutive turns.
281
281
 
282
282
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
283
283
  <!-- CHANGELOG:END -->
package/README.zh.md CHANGED
@@ -265,11 +265,11 @@ CI 通过 `.github/workflows/npm-publish.yml` 发布 — GitHub 发布 release
265
265
  ## 更新日志 (Changelog)
266
266
 
267
267
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
268
+ - **[0.8.22]** (2026-07-11) — A rescan of the working tree turned up a resume-fidelity gap plus two unwired TUI safety-net helpers left by a concurrent session; each was traced, wired where needed, and verified before shipping.
268
269
  - **[0.8.21]** (2026-07-10) — "반영할꺼 같은방식으로 체크하고 배포까지" — a rescan of the working tree (after the prior audit pass) turned up 15 uncommitted files: 3 genuinely distinct features left unwired by a concurrent session, partially casualty of an earlier accidental `git checkout` incident this session (disclosed in the 0.8.20 entry above). Each was independently traced from its existing plumbing, wired to a real call site, tested, and one live-verified end-to-end over a real PTY before shipping.
269
270
  - **[0.8.20]** (2026-07-10) — "모든 검증 다시 리뷰하고 변경사항 모두 체크해" — 4 fresh, skeptical subagents independently re-audited every change from v0.8.17-0.8.19 (bc8768f..92c6b7d) with zero trust in prior claims: a code-correctness auditor manually traced the Antigravity routing logic against the live catalog, a test-integrity auditor mutation-tested the new tests (confirmed each one genuinely fails when its fix is reverted) and ran the suite fresh, a live-behavior verifier re-reproduced all 4 behavioral claims from scratch with self-generated data (own session ids, own mock server, own HTTP status code), and a docs-accuracy auditor cross-checked every README/CHANGELOG claim against current code in isolated git worktrees. Verdict: all core logic and tests GENUINE/CONFIRMED CORRECT — but the audit surfaced 1 real doc staleness gap, 1 changelog count error, and 2 minor precision gaps, all fixed here.
270
271
  - **[0.8.19]** (2026-07-09) — "프롬프트 라우팅에 안티그라비티 프로바이더의 경우, 안티그라비티용 소넷과 오퍼스도 3.5급으로 라우팅될수있도록해줘" — Antigravity re-exports 3 distinct model families (Anthropic Claude, Google Gemini, OpenAI GPT-OSS) behind one credential, structurally unlike every other provider. `high`/`complex` tier auto-select always resolved to Google's Gemini 3.5 rows: Anthropic's real 64,000-token output ceiling lost a same-thinking-tier tie to Google's 65,536 by a margin with no practical significance, and Gemini's 1M-token context further outranked Claude's real 200K window — so `antigravity/claude-sonnet-4-6`/`antigravity/claude-opus-4-6-thinking` were NEVER reachable through auto-select, even though both were already correctly `sizeClass`-tagged into the `high`/`complex` pools.
271
272
  - **[0.8.18]** (2026-07-09) — "라우팅 매 프롬프트마다 변경되는지, route why 의도대로 동작하는지 검증하고 배포할게있는지 확인해줘" — 2 parallel subagents live-verified per-prompt routing + `/route why` (including the previously-only-unit-tested post-call equivalent-model fallback path, now reproduced against a real mock 500-error server) and audited a disconnected `RouteHistory` class shipped as incomplete scaffolding in 0.8.16/0.8.17 (class + 11 tests existed, but nothing ever called it — no `/route history` subcommand, no wiring into the turn loop). Live testing also surfaced one real bug: the TUI footer's model/provider label never updated after a mid-turn fallback, staying on the pre-fallback model for the rest of that turn's render even though the backend decision (`lastRouteDecision`) was already correct.
272
- - **[0.8.17]** (2026-07-09) — "서브에이전트 이용해. 병렬루 부가 나머지모두 개선하고 검증후 배포까지하자" — a prior 4-way parallel subagent cross-verification pass found the README `[!CAUTION]` routing-lock block (added this session for "프롬프트 라우팅이 한번 정해지면 안바뀌는데?") was directionally correct but had 2 real inaccuracies, plus a concrete test-coverage gap: every existing pin/override regression test drove the lock via the `--model` CLI startup flag, never via typing `/model <name>` as an interactive slash command mid-session, and none proved persistence across 3+ consecutive turns.
273
273
 
274
274
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
275
275
  <!-- CHANGELOG:END -->
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jeo-code",
3
- "version": "0.8.21",
3
+ "version": "0.8.22",
4
4
  "description": "Clean, highly optimized AI coding agent using spec-first loop",
5
5
  "type": "module",
6
6
  "main": "src/cli.ts",
@@ -222,23 +222,43 @@ function hasAntigravityOauth(config: RoutingConfig): boolean {
222
222
  return !!(config.oauth?.antigravity || config.oauth?.gemini);
223
223
  }
224
224
 
225
+ /** Builds the routing catalog once per call: static `MODEL_CATALOG` rows need the
226
+ * full `isAutoSelectCandidate` gate (including the `isLiveProviderModel` check,
227
+ * when `openaiBaseUrl` is set), but rows already returned by
228
+ * `liveProviderCatalogModels()` are live BY CONSTRUCTION — re-verifying each one
229
+ * with `isLiveProviderModel` is pure O(n) waste (it dominated this function's
230
+ * cost: ~1.4ms/call at 2000 live models). Live rows still need the shared
231
+ * provider/OAuth gates (cloud provider, credential, antigravity/gemini), just not
232
+ * the live-membership check specific to static rows. */
225
233
  function routingCatalog(config: RoutingConfig): CatalogModel[] {
226
- const rows = [...MODEL_CATALOG, ...liveProviderCatalogModels(config)];
227
- const eligible = rows.filter(m => isAutoSelectCandidate(m, config));
234
+ const staticEligible = MODEL_CATALOG.filter(m => isAutoSelectCandidate(m, config));
235
+ const liveEligible = liveProviderCatalogModels(config).filter(m => isAutoSelectCandidateLive(m, config));
228
236
  const byId = new Map<string, CatalogModel>();
229
- for (const row of eligible) if (!byId.has(row.canonical)) byId.set(row.canonical, row);
237
+ for (const row of staticEligible) if (!byId.has(row.canonical)) byId.set(row.canonical, row);
238
+ for (const row of liveEligible) if (!byId.has(row.canonical)) byId.set(row.canonical, row);
230
239
  return [...byId.values()];
231
240
  }
232
241
 
233
- function isAutoSelectCandidate(m: CatalogModel, config: RoutingConfig): boolean {
234
- // Shared model-LEVEL credential gate (the full auth matrix is documented on
235
- // modelServableWithConfig; keep aligned with resolveCall).
242
+ /** Shared provider/OAuth eligibility gates, independent of live-membership. */
243
+ function isCoreEligible(m: CatalogModel, config: RoutingConfig): boolean {
236
244
  if (!isCloudProvider(m.provider) || m.limitedAvailability || !modelServableWithConfig(m.provider, m.canonical, config)) return false;
237
- if (m.provider === "openai" && config.openaiBaseUrl && !isLiveProviderModel("openai", m.canonical, config)) return false;
238
245
  if (hasAntigravityOauth(config) && m.provider === "gemini") return false;
239
246
  return true;
240
247
  }
241
248
 
249
+ function isAutoSelectCandidate(m: CatalogModel, config: RoutingConfig): boolean {
250
+ if (!isCoreEligible(m, config)) return false;
251
+ if (m.provider === "openai" && config.openaiBaseUrl && !isLiveProviderModel("openai", m.canonical, config)) return false;
252
+ return true;
253
+ }
254
+
255
+ /** Same eligibility as `isAutoSelectCandidate`, for a row ALREADY sourced from
256
+ * `liveProviderCatalogModels()` — skips the live-membership re-check (see
257
+ * `routingCatalog`'s doc comment). */
258
+ function isAutoSelectCandidateLive(m: CatalogModel, config: RoutingConfig): boolean {
259
+ return isCoreEligible(m, config);
260
+ }
261
+
242
262
 
243
263
  /** Size class jeo infers for a catalog model, used to group EQUIVALENT models
244
264
  * across DIFFERENT providers for pool-based routing (see `tierModelPool`) — the
@@ -358,12 +378,10 @@ export function cheapestCredentialed(config: RoutingConfig): string | null {
358
378
  let best: CatalogModel | null = null;
359
379
  let bestCost = Infinity;
360
380
  for (const m of routingCatalog(config)) {
361
- // Candidate eligibility is centralized in `isAutoSelectCandidate`: it keeps
362
- // OAuth-backed Antigravity routing on provider-qualified 3.1+ Gemini rows and
363
- // prevents public `gemini` catalog rows from winning when Antigravity OAuth is
364
- // the available Gemini lane. The real veto gate in launch.ts's `runTurn` still
365
- // re-verifies full readiness (including live OAuth expiry) before a turn uses it.
366
- if (!isAutoSelectCandidate(m, config)) continue;
381
+ // `routingCatalog(config)` already filtered to eligible rows (static rows via
382
+ // `isAutoSelectCandidate`, live rows via `isAutoSelectCandidateLive`) — no need
383
+ // to re-check here. See cheapestCredentialed's own doc comment for the
384
+ // Antigravity/Gemini OAuth rationale behind that eligibility gate.
367
385
  const price = priceForModel(m.canonical);
368
386
  if (!price) continue;
369
387
  const cost = price.inPerM + price.outPerM;
@@ -406,9 +424,8 @@ export function strongestCredentialed(
406
424
  ): string | null {
407
425
  let best: CatalogModel | null = null;
408
426
  for (const m of routingCatalog(config)) {
409
- // Candidate eligibility is centralized in `isAutoSelectCandidate` (see
427
+ // `routingCatalog(config)` already filtered to eligible rows (see
410
428
  // cheapestCredentialed above for the Antigravity/Gemini OAuth rationale).
411
- if (!isAutoSelectCandidate(m, config)) continue;
412
429
  if (filter && !filter(m)) continue;
413
430
  if (!best) { best = m; continue; }
414
431
  const xhighM = m.thinking.includes("xhigh") ? 1 : 0;
@@ -235,6 +235,32 @@ interface LiveProviderModelRecord {
235
235
  }
236
236
  const liveProviderModels = new Map<string, LiveProviderModelRecord>();
237
237
 
238
+ /** Secondary index for `isLiveProviderModel`: `provider\u0000id` -> the set of
239
+ * `liveProviderModels` keys whose row `canonical` or `providerModel` equals that
240
+ * id. Keeps the lookup O(1) (average case) instead of an O(n) scan over every
241
+ * live-discovered model — the scan previously ran once per STATIC catalog row
242
+ * inside `isAutoSelectCandidate` (routingCatalog's OpenAI/`openaiBaseUrl` gate),
243
+ * making `routingCatalog()` O(n*m) whenever an aggregator-style base URL (e.g.
244
+ * OpenRouter, a local proxy) had discovered hundreds of live models — measured
245
+ * ~165ms/call at 2000 live models, on every routed turn.
246
+ */
247
+ const liveModelIdIndex = new Map<string, Set<string>>();
248
+
249
+ function liveIndexKey(provider: ProviderName, id: string): string {
250
+ return `${provider}\u0000${id}`;
251
+ }
252
+
253
+ function addToLiveIndex(provider: ProviderName, id: string, recordKey: string): void {
254
+ if (!id) return;
255
+ const key = liveIndexKey(provider, id);
256
+ let set = liveModelIdIndex.get(key);
257
+ if (!set) {
258
+ set = new Set();
259
+ liveModelIdIndex.set(key, set);
260
+ }
261
+ set.add(recordKey);
262
+ }
263
+
238
264
  function normalizeBaseUrl(baseUrl: string | undefined): string | undefined {
239
265
  return baseUrl?.replace(/\/+$/, "");
240
266
  }
@@ -302,11 +328,14 @@ export function recordLiveProviderModels(
302
328
  const row = known
303
329
  ? { ...known, canonical, provider, providerModel }
304
330
  : liveModelFallbackRow(provider, canonical, providerModel);
305
- liveProviderModels.set(liveModelRecordKey(provider, canonical, baseUrl), {
331
+ const recordKey = liveModelRecordKey(provider, canonical, baseUrl);
332
+ liveProviderModels.set(recordKey, {
306
333
  row,
307
334
  source: opts.source ?? "none",
308
335
  baseUrl,
309
336
  });
337
+ addToLiveIndex(provider, canonical, recordKey);
338
+ addToLiveIndex(provider, providerModel, recordKey);
310
339
  }
311
340
  }
312
341
 
@@ -324,21 +353,30 @@ export function liveProviderCatalogModels(config?: { openaiBaseUrl?: string }):
324
353
  .map(record => record.row);
325
354
  }
326
355
 
327
- /** True when a model came from the current provider/base URL's live model list. */
356
+ /** True when a model came from the current provider/base URL's live model list.
357
+ * O(1) average case via `liveModelIdIndex` — see that Map's doc comment for why
358
+ * this must not be an O(n) scan over every live-discovered model (it runs once
359
+ * per STATIC catalog row inside `isAutoSelectCandidate`). */
328
360
  export function isLiveProviderModel(provider: ProviderName, model: string, config?: { openaiBaseUrl?: string }): boolean {
329
361
  const candidates = new Set([model, liveCanonicalId(provider, model), stripProviderPrefix(provider, model)]);
330
- return [...liveProviderModels.values()].some(record =>
331
- record.row.provider === provider &&
332
- liveRecordMatchesConfig(record, config) &&
333
- (candidates.has(record.row.canonical) || candidates.has(record.row.providerModel))
334
- );
362
+ for (const candidate of candidates) {
363
+ const recordKeys = liveModelIdIndex.get(liveIndexKey(provider, candidate));
364
+ if (!recordKeys) continue;
365
+ for (const recordKey of recordKeys) {
366
+ const record = liveProviderModels.get(recordKey);
367
+ if (record && liveRecordMatchesConfig(record, config)) return true;
368
+ }
369
+ }
370
+ return false;
335
371
  }
336
372
 
337
373
  /** Test-only: clear live provider model supplements. */
338
374
  export function resetLiveProviderModels(): void {
339
375
  liveProviderModels.clear();
376
+ liveModelIdIndex.clear();
340
377
  }
341
378
 
379
+
342
380
  /**
343
381
  * Model ids the Kimi Code OAuth backend (api.kimi.com/coding, Anthropic Messages
344
382
  * format) actually serves. The subscription endpoint rejects Moonshot API-platform
@@ -114,7 +114,9 @@ import {
114
114
  appendCompaction,
115
115
  resolveSessionRef,
116
116
  } from "../agent/session";
117
- import { clearLine, cursorUp, toColumn, truncate as truncateAnsi, size as terminalSize, resetMouseTracking, clearScreen, clearVisible, clearToEnd, enableModifyOtherKeys, disableModifyOtherKeys, enableKittyKeyboard, disableKittyKeyboard } from "../tui/terminal";
117
+ import { clearLine, cursorUp, toColumn, truncate as truncateAnsi, size as terminalSize, resetMouseTracking, clearScreen, clearVisible, clearToEnd, enableModifyOtherKeys, disableModifyOtherKeys, enableKittyKeyboard, disableKittyKeyboard, setCursorColor, resetCursorColor, isTTY, watchResize } from "../tui/terminal";
118
+
119
+
118
120
 
119
121
 
120
122
  import {
@@ -196,6 +198,8 @@ import {
196
198
  contextUsageLines,
197
199
  historyViewLines,
198
200
  } from "./launch/slash-views";
201
+ import { formatTranscript } from "../tui/components/transcript";
202
+
199
203
 
200
204
  import {
201
205
  handleUsage,
@@ -761,17 +765,29 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
761
765
 
762
766
  // pi-style session persistence: resume an existing session or create a new one.
763
767
  let sessionId: string | undefined;
768
+ // True once a resume actually restored PRIOR messages (not just an empty
769
+ // placeholder). Consumed by the "INTERACTIVE mode" welcome-banner block below to
770
+ // skip the fresh-start clearScreen()+banner render — found live: printing the
771
+ // resumed transcript ledger here, then unconditionally wiping the screen and
772
+ // drawing the generic welcome box a few hundred lines later, buried the actual
773
+ // restored work under a "fresh start" banner instead of leaving it on screen.
774
+ let resumedWithHistory = false;
775
+
764
776
  let compactionSeq = 0;
765
777
  if (!flags.noSession) {
766
778
  if (flags.resumeInteractive) {
779
+
767
780
  // Bare `--resume`/`-r` (no value) at cold startup (gjc-parity): the interactive
768
781
  // session picker — added right before the REPL loop starts, below — takes over
769
782
  // and replaces this session before the user ever sees it, but ONLY in a TTY.
770
- // Start with a fresh session here as the safe default either way.
771
- sessionId = (await createSession(cwd, undefined, sessionModel)).id;
772
783
  if (!process.stdin.isTTY || !process.stdout.isTTY) {
773
784
  // No TTY means no picker is possible — fall back to the same silent-latest
774
- // behavior as `--continue` rather than silently doing nothing.
785
+ // behavior as `--continue` rather than silently doing nothing. Resolve the
786
+ // TRUE latest session BEFORE creating any placeholder session below — creating
787
+ // one first would make IT the newest-mtime file, permanently masking the real
788
+ // prior session and turning this fallback into dead code (found live: a bare
789
+ // `--resume` outside a TTY always "resumed" a fresh empty session instead of
790
+ // the actual prior one).
775
791
  const id = await latestSessionId(cwd);
776
792
  if (id) {
777
793
  try {
@@ -781,11 +797,35 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
781
797
  if (!initialSessionModel && header.model) sessionModel = header.model;
782
798
  const modelNote = sessionModel ? ` · model ${sessionModel}` : "";
783
799
  console.log(`Resumed session ${id} (${messages.length} messages).${modelNote}`);
800
+ // Reproduce the prior screen exactly (gjc/gajae-code parity): a bare
801
+ // `--resume` picker with no TTY falls back to the latest session, so
802
+ // this is the ONLY chance to show the prior work before the REPL
803
+ // starts — replay it as a scrollback ledger, same formatter `/session
804
+ // resume` uses mid-session, so a resumed screen always reads like the
805
+ // exact prior conversation instead of a bare message count.
806
+ if (messages.length > 0) {
807
+ resumedWithHistory = true;
808
+ const sep = "─".repeat(Math.min(48, Math.max(20, (process.stdout.columns ?? 80) - 1)));
809
+ console.log([sep, ...formatTranscript(history, { maxTurns: 6, color: true, unicode: true }), sep].join("\n"));
810
+ }
811
+
812
+
784
813
  } catch (err) {
785
814
  console.log(`Could not resume ${id}: ${(err as Error).message}. Starting fresh.`);
786
815
  }
787
816
  }
817
+ // No prior session to resume (fresh cwd), or the resume attempt above failed —
818
+ // safe-default fresh session, same as every other "nothing to resume" path.
819
+ if (!sessionId) {
820
+ sessionId = (await createSession(cwd, undefined, sessionModel)).id;
821
+ }
822
+ } else {
823
+ // TTY: start with a fresh session as the safe default; the interactive picker
824
+ // below (right before the REPL loop) takes over and replaces it before the
825
+ // user ever sees this one, so creating it first here is harmless.
826
+ sessionId = (await createSession(cwd, undefined, sessionModel)).id;
788
827
  }
828
+
789
829
  } else if (flags.resume) {
790
830
  let id: string | undefined;
791
831
  let skipResume = false;
@@ -823,6 +863,17 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
823
863
  if (header.draft) startupDraft = header.draft;
824
864
  const modelNote = sessionModel ? ` · model ${sessionModel}` : "";
825
865
  console.log(`Resumed session ${id} (${messages.length} messages).${modelNote}`);
866
+ // Reproduce the prior screen exactly (gjc/gajae-code parity): same
867
+ // scrollback-ledger replay `/session resume` uses mid-session, so
868
+ // `--resume`/`-c`/`--resume <id>` restores the prior work onto the
869
+ // screen instead of just a bare message count.
870
+ if (messages.length > 0) {
871
+ resumedWithHistory = true;
872
+ const sep = "─".repeat(Math.min(48, Math.max(20, (process.stdout.columns ?? 80) - 1)));
873
+ console.log([sep, ...formatTranscript(history, { maxTurns: 6, color: true, unicode: true }), sep].join("\n"));
874
+ }
875
+
876
+
826
877
  } catch (err) {
827
878
  console.log(`Could not resume ${id}: ${(err as Error).message}. Starting fresh.`);
828
879
  sessionId = (await createSession(cwd, undefined, sessionModel)).id;
@@ -1914,18 +1965,28 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1914
1965
  renderWelcome({ ...welcomeData, cols: terminalSize().cols });
1915
1966
  // gjc-style fresh-start clear so the banner opens atop a clean screen. TTY only,
1916
1967
  // never mid-turn (scrollback flood). ponytail: add an opt-out env if anyone misses their scrollback.
1917
- if (process.stdout.isTTY) process.stdout.write(clearScreen());
1918
- // Launch sweep: the forge mark's gradient loops seamlessly (default 2 full
1919
- // cycles, JEO_WELCOME_ANIM_CYCLES overrides), ending on the static banner.
1920
- // Truecolor TTYs only; JEO_NO_WELCOME_ANIM=1 opts out.
1921
- const sweepable =
1922
- !!process.stdout.isTTY &&
1923
- welcomeTheme.color &&
1924
- detectColorLevel(process.env, true) === ColorLevel.TrueColor &&
1925
- jeoEnv("NO_WELCOME_ANIM") !== "1";
1926
- const sweepCycles = Math.min(10, Math.max(1, Number(jeoEnv("WELCOME_ANIM_CYCLES")) || 2));
1927
- if (sweepable) await playWelcomeSweep(welcomeData, { cycles: sweepCycles });
1928
- else console.log(renderWelcome(welcomeData).join("\n"));
1968
+ // SKIPPED on a resume that actually restored prior messages: the transcript ledger
1969
+ // was already printed above (during session persistence, right after "Resumed
1970
+ // session ... (N messages)"), so clearing here would wipe it and the generic
1971
+ // "fresh start" banner below would bury the just-restored work — live-verified via
1972
+ // a real pty run of `--resume <id>`, which showed exactly that: the resumed
1973
+ // conversation flashed on screen then vanished under the welcome box a moment later.
1974
+ if (!resumedWithHistory) {
1975
+ if (process.stdout.isTTY) process.stdout.write(clearScreen());
1976
+ // Launch sweep: the forge mark's gradient loops seamlessly (default 2 full
1977
+ // cycles, JEO_WELCOME_ANIM_CYCLES overrides), ending on the static banner.
1978
+ // Truecolor TTYs only; JEO_NO_WELCOME_ANIM=1 opts out.
1979
+ const sweepable =
1980
+ !!process.stdout.isTTY &&
1981
+ welcomeTheme.color &&
1982
+ detectColorLevel(process.env, true) === ColorLevel.TrueColor &&
1983
+ jeoEnv("NO_WELCOME_ANIM") !== "1";
1984
+ const sweepCycles = Math.min(10, Math.max(1, Number(jeoEnv("WELCOME_ANIM_CYCLES")) || 2));
1985
+ if (sweepable) await playWelcomeSweep(welcomeData, { cycles: sweepCycles });
1986
+ else console.log(renderWelcome(welcomeData).join("\n"));
1987
+ }
1988
+
1989
+
1929
1990
 
1930
1991
  // Surface the "New version" banner reliably: render ONCE from the on-disk cache
1931
1992
  // instantly (no network wait, works offline — the common path after the first
@@ -2624,11 +2685,28 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
2624
2685
  }
2625
2686
  return out;
2626
2687
  };
2688
+ // Match the REAL terminal cursor's color to the theme accent (OSC 12) so the caret
2689
+ // is never a same-toned block over the input box's typed text — the default terminal
2690
+ // cursor color (usually solid white) otherwise reads as a stray light patch on every
2691
+ // non-mono theme, especially the darker ones, making it hard to spot where typing
2692
+ // lands. Colorless (`mono`) intentionally skips this and any terminal that ignores
2693
+ // OSC 12 (rare) is unaffected either way. Reset on process exit (see below).
2694
+ const applyCursorColor = (): void => {
2695
+ if (isTTY() && uiTheme.color) {
2696
+ try { process.stdout.write(setCursorColor(uiTheme.accent)); } catch { /* terminal gone */ }
2697
+ }
2698
+ };
2627
2699
  const refreshUiTheme = (): void => {
2628
2700
  uiTheme = resolveTheme(process.env);
2629
2701
  uiAccent = accentPaint(uiTheme);
2630
2702
  uiAccentShadow = accentShadowPaint(uiTheme);
2703
+ applyCursorColor();
2631
2704
  };
2705
+ applyCursorColor();
2706
+ process.once("exit", () => {
2707
+ try { process.stdout.write(resetCursorColor()); } catch { /* terminal gone */ }
2708
+ });
2709
+
2632
2710
  // The gjc-layout status bar pinned directly ABOVE the input box: bg-gradient
2633
2711
  // identity block (model · thinking / branch / cwd) left, live ctx% right.
2634
2712
  const statusBarLine = (cols: number): string => {
@@ -2945,7 +3023,12 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
2945
3023
  }, 16);
2946
3024
  };
2947
3025
  process.stdout.on("resize", onPickerResize);
3026
+ // Poll-based safety net (same rationale as the idle footer's watchResize): a
3027
+ // picker left open across a missed 'resize' event would otherwise stay pinned
3028
+ // to its opening geometry until a keypress happened to trigger a repaint.
3029
+ const stopPickerResizeWatch = watchResize(() => onPickerResize());
2948
3030
  try {
3031
+
2949
3032
  await new Promise<void>(resolve => {
2950
3033
  const handler = (ch: string, key: { name?: string; ctrl?: boolean; meta?: boolean; shift?: boolean; sequence?: string } | undefined) => {
2951
3034
  // Same normalization as the footer handler: kitty CSI-u Esc/Ctrl+C must
@@ -2963,7 +3046,9 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
2963
3046
  });
2964
3047
  } finally {
2965
3048
  process.stdout.off("resize", onPickerResize);
3049
+ stopPickerResizeWatch();
2966
3050
  if (resizeTimer) clearTimeout(resizeTimer);
3051
+
2967
3052
  if (process.stdin.setRawMode && !wasRaw) {
2968
3053
  process.stdin.setRawMode(false);
2969
3054
  }
@@ -3716,12 +3801,22 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
3716
3801
  }, RESIZE_THROTTLE_MS);
3717
3802
  };
3718
3803
  process.stdout.on("resize", idleResizeHandler);
3804
+ // Poll-based safety net: the idle footer sits between turns for most of a
3805
+ // session's wall-clock time, so a missed 'resize' event here (tmux pane switch
3806
+ // while this pane isn't foreground, a SIGCONT race, etc. — see terminal.ts's
3807
+ // watchResize doc) is the single biggest source of "screen stays at its
3808
+ // starting size" reports. Cheap 300ms poll; idleResizeHandler already dedupes
3809
+ // via lastIdleAt/reReserveFooter so this composes safely with the real event.
3810
+ const stopIdleResizeWatch = watchResize(() => idleResizeHandler());
3719
3811
  promptListenerCleanups.push(() => {
3720
3812
  process.stdout.off("resize", idleResizeHandler);
3721
3813
  if (idleResizeTimer) clearTimeout(idleResizeTimer);
3814
+ stopIdleResizeWatch();
3722
3815
  });
3723
3816
  }
3724
3817
 
3818
+
3819
+
3725
3820
  // Bare `--resume`/`-r` (no value) at cold startup (gjc-parity): show the SAME
3726
3821
  // interactive session picker `/session resume` (no arg) uses, in place of the
3727
3822
  // fresh session created above. TTY-only — non-TTY already fell back to
package/src/tui/app.ts CHANGED
@@ -11,7 +11,8 @@
11
11
  */
12
12
  import { Renderer } from "./renderer";
13
13
  import { readWorkflowStateStrict } from "../agent/state";
14
- import { size, isTTY, hideCursor, showCursor, enterAltScreen, leaveAltScreen } from "./terminal";
14
+ import { size, isTTY, hideCursor, showCursor, enterAltScreen, leaveAltScreen, watchResize } from "./terminal";
15
+
15
16
  import { Spinner } from "./components/spinner";
16
17
  import { ToolList } from "./components/tool-list";
17
18
  import { StreamRegion } from "./components/stream";
@@ -251,6 +252,10 @@ export class LaunchTui {
251
252
  private lastResizeAt = 0;
252
253
  private lastCols = -1;
253
254
  private lastRows = -1;
255
+ // Stops the poll-based resize safety net (terminal.ts's watchResize) started in
256
+ // begin(); cleared in finish() alongside the 'resize'/SIGCONT listeners.
257
+ private stopResizeWatch: (() => void) | undefined;
258
+
254
259
  private pendingIndex: number | null = null;
255
260
  private pendingTitle: string | null = null;
256
261
  private pendingForge: ForgeSummary | null = null;
@@ -1172,7 +1177,15 @@ export class LaunchTui {
1172
1177
  if (processMax > 0 && resumeCount + 1 >= processMax) process.setMaxListeners(resumeCount + 2);
1173
1178
  process.on("SIGCONT", this.onResume);
1174
1179
  }
1180
+ // Poll-based safety net (terminal.ts's watchResize): catches a resize the TTY
1181
+ // 'resize' event missed (e.g. a tmux pane switch while this pane wasn't the
1182
+ // foreground one — tmux only forwards SIGWINCH to the active pane). Cheap
1183
+ // (300ms comparison poll), fires resizeRepaint() only on an actual geometry
1184
+ // change, and is a no-op alongside a real 'resize' event (whichever notices
1185
+ // first wins; lastCols/lastRows dedupe the other).
1186
+ this.stopResizeWatch = watchResize(() => this.resizeRepaint());
1175
1187
  }
1188
+
1176
1189
  // Animate the spinner + elapsed clock while the model is thinking.
1177
1190
  this.timer = setInterval(() => {
1178
1191
  try {
@@ -1269,7 +1282,12 @@ export class LaunchTui {
1269
1282
  if (process.platform !== "win32") {
1270
1283
  process.removeListener("SIGCONT", this.onResume);
1271
1284
  }
1285
+ if (this.stopResizeWatch) {
1286
+ this.stopResizeWatch();
1287
+ this.stopResizeWatch = undefined;
1288
+ }
1272
1289
  }
1290
+
1273
1291
  if (this.usedAltScreen) {
1274
1292
  // Leave the alt screen (restores the main buffer + scrollback), then print the
1275
1293
  // static summary below the prior output so the turn leaves exactly one record.
@@ -84,6 +84,75 @@ export function size(): { cols: number; rows: number } {
84
84
  rows: process.stdout.rows || 24,
85
85
  };
86
86
  }
87
+ /** True live geometry via `getWindowSize()` — a real `TIOCGWINSZ` ioctl through libuv,
88
+ * bypassing `process.stdout.columns`/`.rows` entirely. Node/Bun only refresh THOSE
89
+ * cached fields when a `'resize'` (SIGWINCH) event fires, which is exactly the signal
90
+ * {@link watchResize} exists to work around (tmux pane/window switch while jeo's pane
91
+ * isn't foregrounded, a SIGCONT race after Ctrl-Z, an SSH/multiplexer layer that only
92
+ * propagates the ioctl update on the next write) — in every one of those, the cached
93
+ * fields go stale and NOTHING re-freshes them without a real event. Returns `null` when
94
+ * unavailable (older runtimes, non-TTY streams — Bun/Node only expose `getWindowSize`
95
+ * on a real `tty.WriteStream`), it throws (handle torn down), or it reports `0,0` (no
96
+ * controlling terminal ever set a winsize, e.g. a bare pty as `bun test` sees). */
97
+ function liveWindowSize(): { cols: number; rows: number } | null {
98
+ const stream = process.stdout as NodeJS.WriteStream & { getWindowSize?: () => [number, number] };
99
+ if (typeof stream.getWindowSize !== "function") return null;
100
+ try {
101
+ const [cols, rows] = stream.getWindowSize();
102
+ return cols > 0 && rows > 0 ? { cols, rows } : null;
103
+ } catch {
104
+ return null;
105
+ }
106
+ }
107
+
108
+ /** Poll-based resize watcher — a SAFETY NET alongside the TTY `'resize'` event.
109
+ * `'resize'` (SIGWINCH) is missed in several real-world cases jeo actually hits: a
110
+ * tmux pane/window switch while jeo's pane isn't the foreground one (tmux only
111
+ * forwards SIGWINCH to the active pane), a SIGCONT race after Ctrl-Z where the resize
112
+ * happened entirely during the stop, and some SSH/multiplexer layers that only
113
+ * propagate the ioctl update on the next write rather than emitting a fresh event.
114
+ * In every one of these, `process.stdout.columns`/`.rows` (and therefore `size()`)
115
+ * go stale and NOTHING short of a real `'resize'` event ever refreshes them — so a
116
+ * naive poll comparing `size()` against itself can never observe a change that
117
+ * already happened, it would just keep comparing one stale cache to itself.
118
+ *
119
+ * Each tick instead asks the OS DIRECTLY via {@link liveWindowSize} (a cheap,
120
+ * allocation-free ioctl — safe every `intervalMs`). When that live read disagrees
121
+ * with the last-seen geometry, this SELF-HEALS the stale cache — writing the
122
+ * corrected values onto `process.stdout.columns`/`.rows` exactly like Node's own
123
+ * SIGWINCH handler would — before firing `onChange`, so every other reader of
124
+ * `size()` (the live-frame `draw()`, `resizeRepaint()`, `idleResizeHandler()`, a
125
+ * picker's `repaint()`) sees the corrected geometry too, not just this callback.
126
+ * When `getWindowSize` is unavailable, falls back to comparing `size()` against
127
+ * itself (the pre-existing behavior) — still catches a genuine cache update that
128
+ * arrived via some OTHER path (e.g. a real `'resize'` event firing between polls).
129
+ * Never fires spuriously — only on an actual geometry change — so it composes
130
+ * safely alongside an existing `'resize'` listener (whichever notices first wins,
131
+ * the other becomes a no-op once both routes converge on the same geometry).
132
+ * Returns a `stop()` to clear the interval; the timer is `unref()`d so it can never
133
+ * keep the process alive on its own. */
134
+ export function watchResize(onChange: (cols: number, rows: number) => void, intervalMs = 300): () => void {
135
+ let last = size();
136
+ const timer = setInterval(() => {
137
+ const live = liveWindowSize();
138
+ const cur = live ?? size();
139
+ if (cur.cols !== last.cols || cur.rows !== last.rows) {
140
+ last = cur;
141
+ if (live) {
142
+ try {
143
+ process.stdout.columns = live.cols;
144
+ process.stdout.rows = live.rows;
145
+ } catch { /* non-writable in this environment — onChange still fires below */ }
146
+ }
147
+ onChange(cur.cols, cur.rows);
148
+ }
149
+ }, intervalMs);
150
+ if (typeof (timer as unknown as { unref?: () => void }).unref === "function") {
151
+ (timer as unknown as { unref: () => void }).unref();
152
+ }
153
+ return () => clearInterval(timer);
154
+ }
155
+
87
156
 
88
157
  export function isTTY(): boolean {
89
158
  return !!process.stdout.isTTY;
@@ -137,3 +206,23 @@ export function enableKittyKeyboard(): string {
137
206
  export function disableKittyKeyboard(): string {
138
207
  return `${ESC}<u`;
139
208
  }
209
+ /** Set the terminal's TEXT CURSOR color via OSC 12 (`\x1b]12;#rrggbb\x07`) — widely
210
+ * supported (xterm, iTerm2, kitty, wezterm, alacritty, ghostty, foot, most tmux/VTE
211
+ * terminals). jeo's input box parks the REAL terminal cursor at the caret (see
212
+ * `input-box.ts`'s `cursorRow`/`cursorCol`) rather than drawing a fake one, so the
213
+ * cursor's on-screen color is entirely up to the terminal's default — usually a
214
+ * solid white/light block. On every theme except `mono` (whose panels/prompt already
215
+ * render colorless), that default cursor color visually MATCHES the light card
216
+ * backgrounds used elsewhere in the UI (e.g. `cardFillPaint`), making the caret
217
+ * nearly impossible to spot against typed text. Setting it to the theme's accent hue
218
+ * gives the caret a color that's always distinct from body text and panel fills. */
219
+ export function setCursorColor(hex: string): string {
220
+ return `\x1b]12;${hex}\x07`;
221
+ }
222
+
223
+ /** Restore the terminal's default cursor color (OSC 112, no color id) — the
224
+ * counterpart to {@link setCursorColor}, sent on exit so a shell/editor started
225
+ * afterward isn't left with jeo's accent-colored cursor. */
226
+ export function resetCursorColor(): string {
227
+ return `\x1b]112\x07`;
228
+ }